Ground handoff map values through explicit canonical fact bindings

This commit is contained in:
Nico committed 2026-10-19 17:34:23 +02:00
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"
]
}
}
}
]
}
+6
View File
@@ -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
+74
View File
@@ -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)
+3
View File
@@ -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']