Ground handoff map values through explicit canonical fact bindings
This commit is contained in:
1 parent
061f82e6f1
commit
c6f4ea9877
10 files changed
+257
-5
No files matched your search
@@ -43,3 +43,38 @@ Neue JSON-Fehlerdiagnosen unterscheiden unter anderem fehlende Trennzeichen,
|
||||
fehlende Schlüssel und nicht abgeschlossene Zeichenketten. Sie melden außerdem,
|
||||
ob der Fehler am Textende liegt. Antworttexte, Feldwerte und ungefilterte
|
||||
Parsermeldungen werden dafür nicht gespeichert.
|
||||
|
||||
|
||||
## Korrektur: benannte Fakten und explizite Profilzuordnung (19.10.2026)
|
||||
|
||||
Neue Übergabekontexte verwenden `parameter_grounding: 2` und speichern
|
||||
`database_fact_bindings`. Eindeutige kanonische Fakten im Format
|
||||
`{id, name, value, provenance}` können nun numerische Werte für gleichnamige,
|
||||
bekannte DB-Spalten belegen. Strings und boolesche Werte werden nicht in Zahlen
|
||||
umgewandelt; mehrdeutige Fakten-IDs werden nicht verwendet.
|
||||
|
||||
Abweichende Feldnamen werden ausschließlich über explizite Profiladapter
|
||||
zugeordnet. Der Vcore-Adapter v1 ordnet aktuell nur den nichtnegativen ganzzahligen
|
||||
wowdiag-Reproduktionsfakt `map_id` der bekannten numerischen Spalte `creature.map`
|
||||
zu. Die Bindung enthält Fakten-ID, Faktenname, Tabelle und Spalte. Der allgemeine
|
||||
Prüfer leitet die erlaubte Bindung erneut aus dem kanonischen Profil/Fakt ab;
|
||||
ein frei hinzugefügter Bindungsdatensatz genügt nicht. Es gibt keine allgemeine
|
||||
`_id`-Heuristik und keine Übertragung von Gebiets-IDs auf Phasenmasken.
|
||||
|
||||
Die Modellansicht enthält die Bindungen und ausschließlich die dazugehörigen
|
||||
kanonischen Fakten, ohne den gesamten Runtime-Faktenkatalog erneut zu senden.
|
||||
Die Rohantwort wird nicht umgeschrieben. Dies ist weiterhin eine notwendige
|
||||
Wertbelegprüfung, kein Beweis, dass jeder beobachtete Wert für jede neue Zeile
|
||||
geeignet ist.
|
||||
|
||||
Bestehende Kontexte behalten Version 1 und ihre bisherige Semantik. Neue Kontexte
|
||||
werden deterministisch mit Version 2 erzeugt und bei Wiederaufnahme erneut
|
||||
gebunden. Bestehende Diagnosen, Fallback-Aufträge und Paketdateien werden nicht
|
||||
nachträglich geändert.
|
||||
|
||||
Offline-Regressionsprüfung: Beide vollständig gespeicherten SQL-Vorlagen aus
|
||||
`bug20-verbose-regression-20261019`, Generation 123, scheitern mit Version 1 an
|
||||
`map = 0`. Mit der belegten Bindung von F0002 (`map_id = 0`) bestehen beide die
|
||||
vollständige Übergabevalidierung, ohne irgendeine Änderung an der LLM-Antwort.
|
||||
Keine zusätzliche LLM-Anfrage erforderlich. Die falsche Zuweisung
|
||||
`phaseMask = 14` bleibt abgelehnt.
|
||||
@@ -120,7 +120,11 @@ class ChangeRequestBuilder:
|
||||
if hashlib.sha256(raw).hexdigest()!=entry['record']['content_sha256']:
|
||||
raise ChangeRequestError('CHANGE_REQUEST_LOCAL_CONTEXT_CHANGED')
|
||||
local[ref]={'kind':next(iter(kinds)),'source_ref':ref,'sha256':hashlib.sha256(raw).hexdigest(),'size':len(raw)}
|
||||
if parameter_grounding:result['parameter_grounding']=1
|
||||
if parameter_grounding:
|
||||
result['parameter_grounding']=parameter_grounding
|
||||
if parameter_grounding == 2:
|
||||
from viano_bug_analysis.profiles.database_bindings import fact_bindings
|
||||
result['database_fact_bindings'] = fact_bindings(result['case'])
|
||||
validate(result, "change-request-context")
|
||||
if len(json_bytes(result)) > self.limits.max_context_bytes:
|
||||
raise ChangeRequestError("CHANGE_REQUEST_CONTEXT_LIMIT")
|
||||
@@ -153,7 +157,7 @@ class ChangeRequestBuilder:
|
||||
policy = old["max_invalid_responses"]
|
||||
if self.config_explicit and policy != self.config.max_invalid_responses:
|
||||
raise ChangeRequestError("CHANGE_REQUEST_POLICY_CHANGED")
|
||||
context = self.context(reader, policy, protocol=old.get("handoff_protocol",1) if started else 2, parameter_grounding=old.get("parameter_grounding",0) if started else 1)
|
||||
context = self.context(reader, policy, protocol=old.get("handoff_protocol",1) if started else 2, parameter_grounding=old.get("parameter_grounding",0) if started else 2)
|
||||
if started and (old != context or started["details"]["context_sha256"] != digest(context)):
|
||||
raise ChangeRequestError("CHANGE_REQUEST_CONTEXT_CHANGED")
|
||||
if completed:
|
||||
|
||||
@@ -46,6 +46,11 @@ def compact_handoff_context(context):
|
||||
view['case'] = {k: context['case'][k] for k in
|
||||
('bug', 'human_observations', 'expected_behavior', 'data_quality')
|
||||
if k in context['case']}
|
||||
if context.get('parameter_grounding') == 2:
|
||||
# Show only actual bound values, not the full runtime fact inventory.
|
||||
view['database_fact_bindings'] = context['database_fact_bindings']
|
||||
ids = {b['fact_id'] for b in context['database_fact_bindings']}
|
||||
view['bound_case_facts'] = [f for f in context['case'].get('runtime_facts', {}).get('facts', []) if f.get('id') in ids]
|
||||
view['evidence'] = {eid: {'content': entry['content'],
|
||||
'content_ref': entry['record']['content_ref'],
|
||||
'content_sha256': entry['record']['content_sha256']}
|
||||
|
||||
@@ -5,13 +5,15 @@ It never substitutes values or modifies model instructions.
|
||||
"""
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from math import isfinite
|
||||
|
||||
|
||||
def unsupported_assignments(payload,context):
|
||||
observed={}
|
||||
table_columns={}
|
||||
def remember(column,value):
|
||||
if (isinstance(column,str) and re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,127}',column)
|
||||
and type(value) in (int,float)):
|
||||
and type(value) in (int,float) and (type(value) is int or isfinite(value))):
|
||||
observed.setdefault(column.lower(),set()).add(Decimal(str(value)))
|
||||
for entry in context.get('evidence',{}).values():
|
||||
for capture in entry['content'].get('items',[]):
|
||||
@@ -19,6 +21,21 @@ def unsupported_assignments(payload,context):
|
||||
query=capture.get('query');result=capture.get('result')
|
||||
if not isinstance(query,dict) or not isinstance(result,dict):continue
|
||||
kind=query.get('kind')
|
||||
if context.get('parameter_grounding') == 2 and kind in {'database.rows','database.schema','database.relations'}:
|
||||
for item in result.get('items', []):
|
||||
if not isinstance(item, dict): continue
|
||||
table = item.get('table') or query.get('table')
|
||||
if not isinstance(table, str): continue
|
||||
columns = table_columns.setdefault(table, set())
|
||||
for col in item.get('columns', []) or []:
|
||||
if (isinstance(col, dict) and isinstance(col.get('COLUMN_NAME'), str)
|
||||
and isinstance(col.get('COLUMN_TYPE'), str)
|
||||
and re.match(r'(?:tinyint|smallint|mediumint|int|bigint|decimal|numeric|float|double)\b', col.get('COLUMN_TYPE', ''), re.I)):
|
||||
columns.add(col['COLUMN_NAME'])
|
||||
rows = item.get('rows', []) if kind == 'database.relations' else [item.get('row', {})]
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
columns.update(k for k, v in row.items() if type(v) in (int, float))
|
||||
if kind=='database.relations':
|
||||
for node in result.get('items',[]):
|
||||
for row in node.get('rows',[]):
|
||||
@@ -44,6 +61,27 @@ def unsupported_assignments(payload,context):
|
||||
elif isinstance(v,list):
|
||||
for item in v:case_values(item)
|
||||
case_values(context.get('case',{}))
|
||||
if context.get('parameter_grounding') == 2:
|
||||
facts = context.get('case', {}).get('runtime_facts', {}).get('facts', [])
|
||||
counts = {}
|
||||
for fact in facts:
|
||||
fid = fact.get('id')
|
||||
if isinstance(fid, str): counts[fid] = counts.get(fid, 0) + 1
|
||||
unique = {f['id']: f for f in facts if counts.get(f.get('id')) == 1}
|
||||
for fact in unique.values():
|
||||
# A named canonical fact is not a free-text model claim.
|
||||
name = fact.get('name')
|
||||
if isinstance(name, str) and name.lower() in observed:
|
||||
remember(name, fact.get('value'))
|
||||
from viano_bug_analysis.profiles.database_bindings import fact_bindings
|
||||
# Re-derive adapter bindings; arbitrary descriptors cannot authorize values.
|
||||
allowed = fact_bindings(context.get('case', {}))
|
||||
for binding in context.get('database_fact_bindings', []):
|
||||
if binding not in allowed: continue
|
||||
fact = unique.get(binding['fact_id'])
|
||||
if (fact and fact.get('name') == binding['fact_name']
|
||||
and binding['column'] in table_columns.get(binding['table'], set())):
|
||||
remember(binding['column'], fact.get('value'))
|
||||
bad=[]
|
||||
for text in payload.get('implementation_guidance',[]):
|
||||
if not isinstance(text,str):continue
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Explicit adapter-owned fact/column bindings; no inferred field aliases."""
|
||||
|
||||
|
||||
def fact_bindings(case):
|
||||
project = case.get('case', {}).get('project', {})
|
||||
if project.get('type') == 'wow' and project.get('profile') == 'vcore':
|
||||
from .vcore.database_bindings import reproduction_bindings_v1
|
||||
return reproduction_bindings_v1(case)
|
||||
return []
|
||||
@@ -0,0 +1,12 @@
|
||||
"""wowdiag reproduction location to Vcore creature schema, version 1."""
|
||||
|
||||
|
||||
def reproduction_bindings_v1(case):
|
||||
# Intentionally narrow: no suffix matching, coordinates, masks or entity IDs.
|
||||
return [{'fact_id': fact['id'], 'fact_name': 'map_id',
|
||||
'table': 'creature', 'column': 'map'}
|
||||
for fact in case.get('runtime_facts', {}).get('facts', [])
|
||||
if fact.get('name') == 'map_id'
|
||||
and isinstance(fact.get('id'), str)
|
||||
and fact.get('provenance', {}).get('kind') == 'reproduction_record'
|
||||
and type(fact.get('value')) is int and fact['value'] >= 0]
|
||||
@@ -77,7 +77,73 @@
|
||||
"const": 2
|
||||
},
|
||||
"parameter_grounding": {
|
||||
"const": 1
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
"database_fact_bindings": {
|
||||
"type": "array",
|
||||
"maxItems": 10000,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"fact_id",
|
||||
"fact_name",
|
||||
"table",
|
||||
"column"
|
||||
],
|
||||
"properties": {
|
||||
"fact_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"fact_name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"table": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"column": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"required": [
|
||||
"parameter_grounding"
|
||||
],
|
||||
"properties": {
|
||||
"parameter_grounding": {
|
||||
"const": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"database_fact_bindings"
|
||||
]
|
||||
},
|
||||
"else": {
|
||||
"not": {
|
||||
"required": [
|
||||
"database_fact_bindings"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -100,6 +100,11 @@ def test_existing_v2_call_stays_frozen_after_upgrade(diagnosed):
|
||||
store, _ = diagnosed
|
||||
adapter = RequestAdapter()
|
||||
builder = ChangeRequestBuilder(store, gateway(adapter))
|
||||
original_context = builder.context
|
||||
def legacy_context(reader, policy, **kwargs):
|
||||
kwargs['parameter_grounding'] = 1
|
||||
return original_context(reader, policy, **kwargs)
|
||||
builder.context = legacy_context
|
||||
original = builder._request
|
||||
builder._request = lambda reader, context, operation, **kw: original(
|
||||
reader, context, operation, version='change_request_v2')
|
||||
@@ -108,6 +113,7 @@ def test_existing_v2_call_stays_frozen_after_upgrade(diagnosed):
|
||||
with pytest.raises(ChangeRequestError):
|
||||
build(builder)
|
||||
builder._request = original
|
||||
builder.context = original_context
|
||||
builder.publish = publish
|
||||
assert build(builder).status == 'COMPLETED'
|
||||
assert build(builder).reused and adapter.calls == 1
|
||||
@@ -0,0 +1,74 @@
|
||||
import copy
|
||||
import pytest
|
||||
from viano_bug_analysis.analysis.database_value_grounding import unsupported_assignments
|
||||
from viano_bug_analysis.profiles.database_bindings import fact_bindings
|
||||
|
||||
|
||||
def context():
|
||||
case = {'case': {'project': {'type': 'wow', 'profile': 'vcore'}},
|
||||
'runtime_facts': {'facts': [{'id': 'F0002', 'name': 'map_id', 'value': 0,
|
||||
'provenance': {'kind': 'reproduction_record', 'source_ref': 'reproduction.json'}}]}}
|
||||
return {'parameter_grounding': 2, 'case': case,
|
||||
'database_fact_bindings': fact_bindings(case),
|
||||
'evidence': {'E000001': {'content': {'items': [{'query': {'kind':'database.rows','table':'creature'},
|
||||
'result': {'items': [{'row': {'map':1,'phaseMask':1,'zoneId':14}}]}}]}}}}
|
||||
|
||||
|
||||
def rejected(c, text='Use map=0.'):
|
||||
return unsupported_assignments({'implementation_guidance':[text]}, c)
|
||||
|
||||
|
||||
def test_canonical_map_zero_authorizes_known_creature_column_without_mutation():
|
||||
c=context(); before=copy.deepcopy(c)
|
||||
assert c['database_fact_bindings']==[{'fact_id':'F0002','fact_name':'map_id','table':'creature','column':'map'}]
|
||||
assert rejected(c)==[] and c==before
|
||||
assert rejected(c,'Use map=2.')==['map']
|
||||
assert rejected(c,'Use phaseMask=14.')==['phasemask']
|
||||
|
||||
|
||||
def test_legacy_context_preserves_rejection_and_exact_named_fact_needs_v2():
|
||||
c=context(); c['parameter_grounding']=1
|
||||
assert rejected(c)==['map']
|
||||
c['parameter_grounding']=2;c['database_fact_bindings']=[]
|
||||
assert rejected(c)==['map']
|
||||
c['case']['runtime_facts']['facts'][0]['name']='map'
|
||||
assert rejected(c)==[]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change',[
|
||||
lambda c: c['case']['case']['project'].update(profile='other'),
|
||||
lambda c: c['case']['runtime_facts']['facts'][0].update(value='0'),
|
||||
lambda c: c['case']['runtime_facts']['facts'][0].update(value=False),
|
||||
lambda c: c['case']['runtime_facts']['facts'][0].update(name='zone_id'),
|
||||
lambda c: c['case']['runtime_facts']['facts'][0]['provenance'].update(kind='tester_assumption'),
|
||||
lambda c: c['database_fact_bindings'][0].update(column='phaseMask'),
|
||||
lambda c: c['database_fact_bindings'][0].update(fact_id='UNKNOWN'),
|
||||
lambda c: c['evidence']['E000001']['content']['items'][0]['query'].update(table='unrelated'),
|
||||
lambda c: c['case']['runtime_facts']['facts'].append(copy.deepcopy(c['case']['runtime_facts']['facts'][0])),
|
||||
])
|
||||
def test_alias_does_not_grant_unbound_or_ambiguous_values(change):
|
||||
c=context();change(c)
|
||||
assert rejected(c)==['map']
|
||||
|
||||
|
||||
def test_unknown_suffix_is_not_an_alias_and_schema_only_binding_is_supported():
|
||||
c=context();f=c['case']['runtime_facts']['facts'][0];f['name']='custom_map_id'
|
||||
c['database_fact_bindings']=fact_bindings(c['case'])
|
||||
assert c['database_fact_bindings']==[] and rejected(c)==['map']
|
||||
f['name']='map_id';c['database_fact_bindings']=fact_bindings(c['case'])
|
||||
cap=c['evidence']['E000001']['content']['items'][0]
|
||||
cap['query']['kind']='database.schema'
|
||||
cap['result']['items']=[{'table':'creature','columns':[{'COLUMN_NAME':'map','COLUMN_TYPE':'smallint(5) unsigned'}]}]
|
||||
assert rejected(c)==[]
|
||||
|
||||
|
||||
def test_full_handoff_validator_accepts_bound_map_but_still_rejects_wrong_mask():
|
||||
from test_handoff_outcomes import minimal, template
|
||||
from viano_bug_analysis.analysis.change_request_contracts import validate_request, ChangeRequestError
|
||||
payload,c=minimal();new=context();c.update({k:new[k] for k in ('case','parameter_grounding','database_fact_bindings')})
|
||||
c['evidence']['E000001']['content']=new['evidence']['E000001']['content']
|
||||
for item in c['evidence']['E000001']['content']['items']:item['source_ref']='code.cpp'
|
||||
template(payload,c);payload['implementation_guidance']=['Use map=0 and {{POSITION_X}}.']
|
||||
validate_request(payload,c)
|
||||
payload['implementation_guidance']=['Use map=0, phaseMask=14 and {{POSITION_X}}.']
|
||||
with pytest.raises(ChangeRequestError,match='UNGROUNDED_DATABASE_VALUE'):validate_request(payload,c)
|
||||
@@ -45,6 +45,9 @@ def test_template_and_human_data_task_are_completed_handoffs(diagnosed, tmp_path
|
||||
result=build(ChangeRequestBuilder(store,g))
|
||||
assert result.status=='COMPLETED' and result.failure_ref is None
|
||||
with PackageReader(original) as old, PackageReader(result.package) as reader:
|
||||
context=json.loads(reader.read_bytes(reader.manifest['artifacts']['change_request_context']))
|
||||
assert context['parameter_grounding'] == 2
|
||||
assert context['database_fact_bindings'] == [] # project-independent fixture
|
||||
coding=json.loads(reader.read_bytes(reader.manifest['artifacts']['change_request']))
|
||||
assert coding['assignment']=='assigned' and coding['request']['status']=='template'
|
||||
assert coding['human_dev_assigned'] is True and 'human_dev' not in coding['request']
|
||||
|
||||
Reference in new issue
Block a user