From c6f4ea9877bb5e15700dbe13ee4cbeeea193835d Mon Sep 17 00:00:00 2001 From: Nitsu45 Date: Mon, 19 Oct 2026 17:34:23 +0200 Subject: [PATCH] Ground handoff map values through explicit canonical fact bindings --- docs/parameter-grounding-and-bootstrap-v21.md | 35 +++++++++ .../analysis/change_request_builder.py | 8 +- .../analysis/change_request_contracts.py | 5 ++ .../analysis/database_value_grounding.py | 40 +++++++++- .../profiles/database_bindings.py | 9 +++ .../profiles/vcore/database_bindings.py | 12 +++ .../change-request-context-v1.schema.json | 70 +++++++++++++++++- tests/test_compact_handoff.py | 6 ++ tests/test_database_fact_grounding.py | 74 +++++++++++++++++++ tests/test_handoff_outcomes.py | 3 + 10 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 src/viano_bug_analysis/profiles/database_bindings.py create mode 100644 src/viano_bug_analysis/profiles/vcore/database_bindings.py create mode 100644 tests/test_database_fact_grounding.py diff --git a/docs/parameter-grounding-and-bootstrap-v21.md b/docs/parameter-grounding-and-bootstrap-v21.md index c6d8c3d..661a7ec 100644 --- a/docs/parameter-grounding-and-bootstrap-v21.md +++ b/docs/parameter-grounding-and-bootstrap-v21.md @@ -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. diff --git a/src/viano_bug_analysis/analysis/change_request_builder.py b/src/viano_bug_analysis/analysis/change_request_builder.py index a417ce4..d3b6c1c 100644 --- a/src/viano_bug_analysis/analysis/change_request_builder.py +++ b/src/viano_bug_analysis/analysis/change_request_builder.py @@ -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: diff --git a/src/viano_bug_analysis/analysis/change_request_contracts.py b/src/viano_bug_analysis/analysis/change_request_contracts.py index 247c25d..87c232a 100644 --- a/src/viano_bug_analysis/analysis/change_request_contracts.py +++ b/src/viano_bug_analysis/analysis/change_request_contracts.py @@ -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']} diff --git a/src/viano_bug_analysis/analysis/database_value_grounding.py b/src/viano_bug_analysis/analysis/database_value_grounding.py index f2f4954..bef419f 100644 --- a/src/viano_bug_analysis/analysis/database_value_grounding.py +++ b/src/viano_bug_analysis/analysis/database_value_grounding.py @@ -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 diff --git a/src/viano_bug_analysis/profiles/database_bindings.py b/src/viano_bug_analysis/profiles/database_bindings.py new file mode 100644 index 0000000..c52fc7c --- /dev/null +++ b/src/viano_bug_analysis/profiles/database_bindings.py @@ -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 [] diff --git a/src/viano_bug_analysis/profiles/vcore/database_bindings.py b/src/viano_bug_analysis/profiles/vcore/database_bindings.py new file mode 100644 index 0000000..52c254d --- /dev/null +++ b/src/viano_bug_analysis/profiles/vcore/database_bindings.py @@ -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] diff --git a/src/viano_bug_analysis/schemas/change-request-context-v1.schema.json b/src/viano_bug_analysis/schemas/change-request-context-v1.schema.json index e00600a..928455d 100644 --- a/src/viano_bug_analysis/schemas/change-request-context-v1.schema.json +++ b/src/viano_bug_analysis/schemas/change-request-context-v1.schema.json @@ -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" + ] + } + } + } + ] } diff --git a/tests/test_compact_handoff.py b/tests/test_compact_handoff.py index f35b43d..e66711f 100644 --- a/tests/test_compact_handoff.py +++ b/tests/test_compact_handoff.py @@ -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 diff --git a/tests/test_database_fact_grounding.py b/tests/test_database_fact_grounding.py new file mode 100644 index 0000000..0fa31f5 --- /dev/null +++ b/tests/test_database_fact_grounding.py @@ -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) diff --git a/tests/test_handoff_outcomes.py b/tests/test_handoff_outcomes.py index 99dd7c3..e7ba119 100644 --- a/tests/test_handoff_outcomes.py +++ b/tests/test_handoff_outcomes.py @@ -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']