Rank source context using grounded typed anchors and scoped searches

This commit is contained in:
Nico committed 2026-10-18 22:29:22 +02:00
1 parent de3a37bfd0
commit af2ee6cb91
13 files changed
+756 -19

No files matched your search

+13
View File
@@ -245,3 +245,16 @@ max_files = 8
max_blocks = 12
max_total_lines = 160
registration_prefixes = ["AddSC_"]
# Context-aware source selection. Domain markers belong to this adapter.
protocol = 2
max_seed_terms = 8
max_candidate_matches = 50
max_weak_hits = 8
code_prefixes = ["src/"]
schema_prefixes = ["sql/base/"]
historical_prefixes = ["sql/old/", "sql/archive/"]
context_fact_names = ["zone_name", "area_name", "world_event_name"]
type_markers = {creature = ["NPC", "CREATURE"], quest = ["QUEST"], spell = ["SPELL"], item = ["ITEM"], gameobject = ["GO", "GAMEOBJECT"]}
entity_tables = [{table = "creature_template", entity_type = "creature", id_column = "entry", script_columns = ["ScriptName"]}]
focus_roles = ["diagnostic_focus", "explicit_quest"]
+118
View File
@@ -0,0 +1,118 @@
# Kontextbezogene Source-Auswahl v2
Die Überarbeitung übernimmt Prinzipien aus der untersuchten wowsrc-0.7-Referenz:
Suchanker tragen Herkunft und Rolle; Wiederholungen einer Zahl erzeugen keine
zusätzliche Relevanz; eine leere primäre Auswahl ist zulässig. Es werden keine
wowsrc-Programme ausgeführt und keine vollständigen Dateien pauschal an eine LLM
übertragen. `docs/architecture.md` bleibt unverändert.
## Anfrage und Herkunft
```json
{"kind":"source.context","text":"23487","path_prefix":"","entity_refs":["N0005"],"scope":"code"}
```
`entity_refs` und `scope` sind optional. Die Referenzen müssen auf existierende
Entitäten im kanonischen Interpretationskontext zeigen; erfundene Entitäten oder
freie Modell-Ankerlisten sind unzulässig. Ohne Referenzen ordnet der Controller
eindeutige IDs oder Namen selbst zu. Bei mehreren Entitätstypen mit gleicher ID
wird kein Typ für eine reine Zahl geraten. Die im Interpretationskontext vorhandene
kontextuelle Namensauflösung bleibt eine beobachtungsbasierte Zuordnung, kein
Beweis für Ursache oder Rolle.
Der Controller ergänzt begrenzt Namen, Schreibvarianten, konfigurierte Fokusrollen
und Kontextfakten. Gleicher Name und seine Schreibvarianten bleiben dieselbe
Ankergruppe. Scriptnamen werden nur aus zugeordneten, erfassten DB-Spalten gelesen;
die Fallzusammenfassung allein genügt dafür nicht. Im Vcore-Adapter ist dies zunächst
`creature_template.entry` → `ScriptName`. Die Konfiguration kann weitere tatsächlich
vorliegende Datensätze ergänzen; das Werkzeug führt dafür keine zusätzlichen
Datenbankabfragen aus. Ein bereits belegter Scriptname lässt sich auch direkt als
Suchbegriff verwenden.
Die ausgewählten Anker werden mit Quelle, JSON-Pointer, Typ und Rollen im Capture
gespeichert. Case- und Interpretationskontext erhalten Referenz und Hash in der
Capture-Bindung. Die Werkzeugkonfiguration und die Anker gehen in den lokalen
Abfragecache ein. Gleiche Anfragen mit anderen belegten Ankern sind andere Captures.
## Auswahlregeln
- Scriptanker und ausdrücklich angefragte Symbole liefern starke Suchkandidaten.
Ein bekannter Anzeigename wird dadurch nicht zu einem Scriptnamen umgedeutet.
- Namen und Kontextanker liefern ergänzenden Kontext. Groß-/Kleinschreibung und
Unterstrichvarianten sind keine unabhängige Bestätigung.
- Eine Zahl allein bleibt eine schwache Fundstelle. Nur eine zur belegten
Entitätsart passende konfigurierte Namensraum-Bindung, beispielsweise
`NPC_… = 23487`, kann sie zum primären Kandidaten machen. Ein `SPELL_…`-Treffer
mit derselben Zahl ist kein NPC-Beleg.
- Solche Bindungen bleiben Textheuristiken, keine semantische C++-Analyse. Auch
ein passend benannter Zähler könnte falsch eingeordnet werden; die LLM muss
den tatsächlichen Code bewerten.
- Pro Datei und Ankergruppe zählt nur das stärkste Signal. Häufige Wiederholungen
und mehrere unzugeordnete Zahlen erzeugen keinen primären Kandidaten.
- Einfache, typisierte Konstantenbindungen können zu Symbol-/Registrierungssuchen
führen. Eine bloße Zahl führt nicht zur Erkundung sämtlicher Registrierungen
ihrer zufällig getroffenen Datei.
- Bekannte andere Ortszuordnungen werden über optionale Operatorregeln als
ergänzender Kontext eingeordnet. Sie werden nicht ausgeschlossen: gemeinsame
Infrastruktur und gebietsübergreifende Mechanismen können relevant sein.
Die Auswahl enthält `primary_candidate`, `supporting_context`,
`supporting_other_location` oder `weak_numeric`. Schwache Zahlentreffer erhalten
nur kurze Fundstellenübersichten; es gibt keinen Ersatz-Primärtreffer, wenn kein
starkes Signal gefunden wurde. Die Klassifikation beschreibt Suchrelevanz, niemals
Confidence in eine Bugursache.
## Suchräume und Grenzen
`code` ist der Standard. Der Vcore-Adapter beschränkt ihn auf `src/` und schließt
SQL-Dateien aus. `schema` nutzt konfigurierte Schemaverzeichnisse, hier `sql/base/`.
`historical_sql` muss ausdrücklich angefordert werden und nutzt die konfigurierten
Archivverzeichnisse. Ein nicht zum Suchraum passender `path_prefix` wird kontrolliert
abgelehnt. Ein leerer Prefix bedeutet in v2 den konfigurierten Suchraum, nicht die
gesamte Repositorywurzel.
Numerische Suchen berücksichtigen vollständige dezimale Tokens; Teile größerer
Zahlen, Bezeichner und Fließkommazahlen zählen nicht als ID-Treffer. Andere
Schreibweisen wie hexadezimale IDs sind damit nicht ausgeschlossen oder abgedeckt.
Anzahl von Ankern, Kandidatentreffern, Suchen, schwachen Zusammenfassungen, Dateien,
Zeilen und Blöcken ist konfigurierbar begrenzt. Treffer werden vor der Auswahl von
Codeausschnitten gewichtet. Relevante Ausschnitte werden zusammengeführt; Code
wird nicht nochmals in jeder Trefferbeschreibung wiederholt. Fehlende Kontextzeilen
und gekürzte Zeilen werden gemeldet. Such-, Seed- und Ausgabelimits verhindern
weiterhin Vollständigkeitsbehauptungen außerhalb des geprüften Ausschnitts.
`selection.primary_files` darf leer sein. Das heißt lediglich, dass unter den
untersuchten Treffern kein starker Kandidat vorliegt. Es schließt dynamische Pfade,
generierten Code, andere Suchräume, nicht untersuchte Anker oder andere
Darstellungsformen nicht aus. Die bisherigen Grundwerkzeuge bleiben verfügbar.
## Aktivierung und Kompatibilität
`[source_analysis] protocol = 2` aktiviert die neue Auswahl. Projektbezogene
Namensräume, DB-Spalten und Suchverzeichnisse stehen im Projektprofil, nicht als
Vcore-Wissen im Kernalgorithmus. `focus_roles`, `context_fact_names`,
`type_markers`, `entity_tables` und optionale `location_paths` sind Operatorregeln.
Neue v2-Einstellungen ohne expliziten Protokollwechsel werden abgelehnt.
v1-Katalog, Argumentvertrag und Algorithmus bleiben für alte Konfigurationen
erhalten. Gespeicherte Kontexte werden nicht migriert; das Ausführen unter
abweichenden Einstellungen wird weiterhin kontrolliert abgelehnt. Bestehende
Prompts bleiben aus ihren gespeicherten Kontexten reproduzierbar.
## Prüfung
Lokale Tests behandeln gleiche Zahlen in unterschiedlichen Namensräumen,
massenhafte Wiederholung, Namensvarianten, reine Zahlen ohne primäre Kandidaten,
Quelltext trotz veränderter Arbeitskopie, unterschiedliche Orte bei erhaltenem
Querverweis, explizite SQL-Suchräume, ungültige Anker, Grenzen, Herkunft und
budgetierte Paket-Wiederaufnahme.
Der lesende Probeaufruf am vorhandenen Bug-20-Paket wählt N0005 (Zielkreatur) und
N0004 (Questkontext) aus. Die im Codebereich geprüften Anker ergeben keinen starken
primären Kandidaten und keine historische SQL-Sammlung; das Suchbudget wird dabei
ausdrücklich als begrenzt gemeldet. Dies ist ein lokaler Werkzeugtest, keine neue
LLM-Bewertung des Bugs und kein Nachweis realer Token-/Kostenersparnis.
Die zuvor bekannte fehlende Gateway-Schemafehlerdiagnostik für Bootstrap v21 ist
ein separater Punkt und wird durch diese Source-Überarbeitung nicht behoben.
@@ -20,7 +20,8 @@ def output_schema(context=None, *, strict=False):
schema['properties']['next'] = extend_tool_schema(schema['properties']['next'])
if context and context['tool_schema'].get('x-project-bindings', {}).get('source_analysis'):
from viano_bug_analysis.tools.source_context import extend_tool_schema
schema['properties']['next'] = extend_tool_schema(schema['properties']['next'])
protocol=context['tool_schema']['x-project-bindings']['source_analysis']['version']
schema['properties']['next'] = extend_tool_schema(schema['properties']['next'],protocol)
if context and context.get('prompt_version') in {'investigation_project_v12','investigation_project_v13','investigation_project_v14','investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
schema['properties']['update']['properties']['causal_review'] = json.loads(
files('viano_bug_analysis.schemas').joinpath('investigation-causal-review-v1.schema.json').read_text())
@@ -260,7 +260,7 @@ class Investigator:
from viano_bug_analysis.tools.source_context import extend_tool_schema
context['tool_schema']['x-project-bindings']['source_analysis'] = service.source_context.catalog()
items = context['tool_schema']['properties']['requests']['items']
context['tool_schema']['properties']['requests']['items'] = extend_tool_schema(items)
context['tool_schema']['properties']['requests']['items'] = extend_tool_schema(items, service.source_context.config.protocol)
if service.relations or service.source_context:
context['tool_guidance_version'] = 1
else:
@@ -32,8 +32,14 @@ def instructions(context):
if bindings.get('source_analysis'):
text += ('source.context arguments: {"kind":"source.context",'
'"text":"SYMBOL_OR_IDENTIFIER_FROM_EVIDENCE","path_prefix":""}. '
'Replace text with the actual term; empty path_prefix means repository root. '
'No registration, offset, limit or command argument.\n')
'Replace text with the actual term; empty path_prefix means ')
text += ('the configured scope prefixes. ' if bindings['source_analysis'].get('version')==2 else 'repository root. ')
text += 'No registration, offset, limit or command argument.\n'
if bindings['source_analysis'].get('version')==2:
text += ('source.context v2 optionally accepts entity_refs:["N0001"] from supplied case entities '
'and scope:"code"|"schema"|"historical_sql" (default code). Grounded names and scripts '
'are added by the controller; numeric coincidences are weak summaries, not identities. '
'Ranking is heuristic; supporting and other-location candidates may matter.\n')
text += ('For primitive database.rows/database.count, filters MUST be a nonempty array of objects, '
'for example [{"column":"observed_column","operator":"eq","value":123}]. '
'Replace the illustrative column/value with schema-verified, case-grounded inputs. '
@@ -730,6 +730,22 @@
"path_prefix": {
"type": "string",
"maxLength": 250
},
"scope": {
"enum": [
"code",
"schema",
"historical_sql"
]
},
"entity_refs": {
"type": "array",
"maxItems": 4,
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^N[0-9]{4,}$"
}
}
}
}
@@ -1731,6 +1731,22 @@
"path_prefix": {
"type": "string",
"maxLength": 250
},
"scope": {
"enum": [
"code",
"schema",
"historical_sql"
]
},
"entity_refs": {
"type": "array",
"maxItems": 4,
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^N[0-9]{4,}$"
}
}
}
}
@@ -285,6 +285,22 @@
"path_prefix": {
"type": "string",
"maxLength": 250
},
"scope": {
"enum": [
"code",
"schema",
"historical_sql"
]
},
"entity_refs": {
"type": "array",
"maxItems": 4,
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^N[0-9]{4,}$"
}
}
}
}
@@ -385,6 +401,9 @@
"items": {
"type": "string"
}
},
"selection": {
"type": "object"
}
}
}
+13 -4
View File
@@ -97,12 +97,15 @@ class ProjectResearch:
schema = extend_query_schema(self.schema()) if self.relations else self.schema()
if self.source_context:
from .source_context import extend_query_schema
schema = extend_query_schema(schema)
schema = extend_query_schema(schema, self.source_context.config.protocol)
return schema
def prepare(self, context, request):
self.check_project(context.reader.manifest["project"])
self.validate(request["arguments"])
if request['arguments']['kind']=='source.context' and self.source_context.config.protocol==2:
from .source_relevance import case_seeds
case_seeds(context,request['arguments'],self.source_context.config)
def validate(self, arguments):
from jsonschema import Draft202012Validator
@@ -125,7 +128,7 @@ class ProjectResearch:
return
provider.validate(arguments)
def collect(self, arguments):
def collect(self, arguments, *, source_context=None):
self.validate(arguments)
provider = self.database if arguments["kind"].startswith("database.") else self.source
binding = provider.binding()
@@ -138,6 +141,8 @@ class ProjectResearch:
settings = self.source_context.catalog()
binding = {**binding, 'source_analysis': settings,
'source_analysis_sha256': hashlib.sha256(json_bytes(settings)).hexdigest()}
if self.source_context.config.protocol==2:
binding['anchor_context']=copy.deepcopy(source_context or {'anchors':[],'omitted_anchors':0})
cache_key = hashlib.sha256(json_bytes({"arguments": arguments, "binding": binding})).hexdigest()
# A request is frozen for the controller's validation/persistence cycle.
# Durable resume uses the captured reply in the package, never this cache.
@@ -145,7 +150,7 @@ class ProjectResearch:
if arguments['kind'] == 'database.relations':
result = self.relations.query(copy.deepcopy(arguments))
elif arguments['kind'] == 'source.context':
result = self.source_context.query(copy.deepcopy(arguments))
result = self.source_context.query(copy.deepcopy(arguments),seed_context=source_context)
else:
result = provider.query(copy.deepcopy(arguments))
capture = {"format": "viano-project-capture", "format_version": 1,
@@ -163,7 +168,11 @@ class ProjectResearch:
def execute(self, context, request):
self.prepare(context, request)
data = self.collect(request["arguments"])
seeds=None
if request['arguments']['kind']=='source.context' and self.source_context.config.protocol==2:
from .source_relevance import case_seeds
seeds=case_seeds(context,request['arguments'],self.source_context.config)
data = self.collect(request["arguments"],source_context=seeds)
capture = json.loads(data)
sha = hashlib.sha256(data).hexdigest()
return {"matches": capture["result"]["items"], "coverage": capture["result"]["coverage"],
@@ -167,6 +167,10 @@ class GitSourceProvider:
continue
if '_extensions' in args and name.rsplit('.', 1)[-1] not in args['_extensions']:
continue
if any(name.startswith(p) for p in args.get('_exclude_prefixes',[])):
continue
if args.get('_numeric_token') and not re.search(r'(?<![\w.])'+re.escape(args['text'])+r'(?:[uUlL]{0,3})(?![\w.])',text):
continue
if name not in sources:
try:
_, sources[name] = self._read(name)
+48 -11
View File
@@ -3,32 +3,37 @@ import copy
import math
import re
import time
from dataclasses import asdict, dataclass, replace
from dataclasses import asdict, dataclass, replace, field
from viano_bug_analysis.lifecycle.storage import json_bytes
from .config import ToolError
from .repository import safe_path
def request_schema():
return {'type': 'object', 'additionalProperties': False,
def request_schema(protocol=1):
schema = {'type': 'object', 'additionalProperties': False,
'required': ['kind', 'text', 'path_prefix'], 'properties': {
'kind': {'const': 'source.context'},
'text': {'type': 'string', 'minLength': 1, 'maxLength': 200},
'path_prefix': {'type': 'string', 'maxLength': 250}}}
def extend_query_schema(schema):
schema = copy.deepcopy(schema)
schema['oneOf'].append(request_schema())
if protocol == 2:
schema['properties'].update(scope={'enum': ['code', 'schema', 'historical_sql']},
entity_refs={'type':'array','maxItems':4,'uniqueItems':True,
'items':{'type':'string','pattern':'^N[0-9]{4,}$'}})
return schema
def extend_tool_schema(schema):
def extend_query_schema(schema, protocol=1):
schema = copy.deepcopy(schema)
schema['oneOf'].append(request_schema(protocol))
return schema
def extend_tool_schema(schema, protocol=1):
schema = copy.deepcopy(schema)
for branch in schema.get('oneOf', []):
if branch.get('properties', {}).get('operation', {}).get('const') == 'project.query':
branch['properties']['arguments'] = extend_query_schema(branch['properties']['arguments'])
branch['properties']['arguments'] = extend_query_schema(branch['properties']['arguments'], protocol)
return schema
@@ -44,6 +49,18 @@ class SourceContextConfig:
registration_prefixes: tuple = ()
extensions: tuple = ('cpp', 'h', 'hpp', 'c', 'sql', 'py', 'js', 'ts', 'tsx',
'cs', 'go', 'rs', 'lua', 'java', 'cmake', 'txt')
protocol: int = 1
max_seed_terms: int = 8
max_candidate_matches: int = 50
max_weak_hits: int = 8
code_prefixes: tuple = ()
schema_prefixes: tuple = ()
historical_prefixes: tuple = ()
context_fact_names: tuple = ()
focus_roles: tuple = ('diagnostic_focus',)
type_markers: dict = field(default_factory=dict)
entity_tables: tuple = ()
location_paths: dict = field(default_factory=dict)
def __post_init__(self):
bounds = {'context_lines': 100, 'max_searches': 12, 'max_matches': 100,
@@ -61,6 +78,8 @@ class SourceContextConfig:
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
if not self.extensions:
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
from .source_relevance import validate_config
validate_config(self)
class SourceContextAnalysis:
@@ -70,11 +89,23 @@ class SourceContextAnalysis:
self.config = SourceContextConfig(**settings)
except (TypeError, ValueError):
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG') from None
if self.config.protocol==1 and set(settings) & {'max_seed_terms','max_candidate_matches','max_weak_hits',
'code_prefixes','schema_prefixes','historical_prefixes','context_fact_names','focus_roles',
'type_markers','entity_tables','location_paths'}:
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
if self.config.enabled and (not callable(getattr(provider, '_read', None)) or not callable(getattr(provider, 'query', None))):
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
def catalog(self):
settings = {k: list(v) if isinstance(v, tuple) else v for k, v in asdict(self.config).items()}
if self.config.protocol == 2:
return {'version':2,'settings':settings,
'preference':'Use source.context with case-grounded entity_refs when available. Default scope=code; '
'schema and historical_sql are explicit scopes. Numeric coincidences remain weak summaries; '
'ranking is heuristic, not identity, causation or a complete call graph.'}
for key in ('protocol','max_seed_terms','max_candidate_matches','max_weak_hits','code_prefixes',
'schema_prefixes','historical_prefixes','context_fact_names','focus_roles','type_markers','entity_tables','location_paths'):
settings.pop(key)
return {'version': 1, 'settings': settings,
'preference': 'Prefer source.context for a symbol or entity identifier before separate '
'search/read/registration calls. Returned links are lexical candidates, '
@@ -85,7 +116,13 @@ class SourceContextAnalysis:
if not args['text'].strip() or any(ord(c) < 32 or ord(c) == 127 for c in args['text']):
raise ToolError('PROJECT_SOURCE_TEXT')
def query(self, args):
def query(self, args, *, seed_context=None):
if self.config.protocol == 2:
from .source_relevance import analyze
return analyze(self, args, seed_context)
return self._query_v1(args)
def _query_v1(self, args):
self.validate(args)
config, provider = self.config, self.provider
deadline = time.monotonic() + provider.limits.timeout_seconds
@@ -0,0 +1,325 @@
"""v2 source selection: grounded seeds, namespace candidates, no numeric promotion."""
import copy
import hashlib
import math
import re
import time
from dataclasses import replace
from viano_bug_analysis.lifecycle.storage import json_bytes
from .config import ToolError
from .repository import safe_path
def validate_config(c):
if type(c.protocol) is not int or c.protocol not in {1,2}:
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
for key, maximum in [('max_seed_terms',24),('max_candidate_matches',500),('max_weak_hits',50)]:
if type(getattr(c,key)) is not int or not 1 <= getattr(c,key) <= maximum:
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
for values in [c.code_prefixes,c.schema_prefixes,c.historical_prefixes]:
if not isinstance(values,(tuple,list)) or len(values)>8:raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
for value in values:safe_path(value)
for values in [c.context_fact_names,c.focus_roles]:
if not isinstance(values,(tuple,list)) or len(values)>12:
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
for value in values:
if not isinstance(value,str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}',value):
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
if not isinstance(c.type_markers,dict) or len(c.type_markers)>20:
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
for key,values in c.type_markers.items():
if not isinstance(key,str) or not isinstance(values,list) or not 1 <= len(values)<=10:
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
if any(not isinstance(v,str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}',v) for v in values):
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
if not isinstance(c.entity_tables,(list,tuple)) or len(c.entity_tables)>20:
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
for rule in c.entity_tables:
if not isinstance(rule,dict) or set(rule)!={'table','entity_type','id_column','script_columns'}:
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
if not isinstance(rule['script_columns'],list) or len(rule['script_columns'])>5:
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
for value in [rule['table'],rule['entity_type'],rule['id_column'],*rule['script_columns']]:
if not isinstance(value,str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,63}',value):
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
if not isinstance(c.location_paths,dict) or len(c.location_paths)>300:
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
for name,paths in c.location_paths.items():
if not isinstance(name,str) or not 1<=len(name)<=100 or not isinstance(paths,list) or len(paths)>8:
raise ToolError('PROJECT_SOURCE_CONTEXT_CONFIG')
for path in paths:safe_path(path)
def case_seeds(context,args,config):
"""Only canonical records and operator-typed DB columns become extra anchors."""
from viano_bug_analysis.case.contracts import read_json
reader=context.reader
ref=reader.manifest['artifacts'].get('interpretation_context')
interpreted=read_json(reader,ref,'case-interpretation-context',4*1024*1024) if ref else None
sources=interpreted['sources'] if interpreted else {}
entities={sid:entry['data'] for sid,entry in sources.items() if entry.get('kind')=='entity'}
selected=args.get('entity_refs',[])
if any(sid not in entities for sid in selected):raise ToolError('PROJECT_SOURCE_ENTITY_REF')
def names(e):
values=[e.get('name')]+[v.get('name') for v in e.get('name_bindings',[])]
return [v for v in values if isinstance(v,str) and v.strip()]
if not selected:
selected=[sid for sid,e in entities.items() if str(e.get('id'))==args['text']
or args['text'].casefold() in [n.casefold() for n in names(e)]]
ambiguous=not args.get('entity_refs') and len({entities[sid].get('type') for sid in selected})>1
# Related explicit focus is context, never a replacement for the affected entity.
selected=list(dict.fromkeys(selected+[sid for sid,e in entities.items()
if set(e.get('roles',[])) & set(config.focus_roles)]))
omitted_entities=max(0,len(selected)-8);selected=selected[:8]
anchors=[]
def add(term,kind,group,source_ref,pointer,**extra):
if type(term) in (str,int) and str(term).strip() and len(str(term))<=200:
anchors.append({'term':str(term),'kind':kind,'group':group,'source_ref':source_ref,
'json_pointer':pointer,**extra})
for sid in selected:
e=entities[sid];extra={'entity_type':None if ambiguous else e.get('type'),
'entity_id':str(e.get('id')),'roles':e.get('roles',[])}
add(e.get('id'),'id',sid+':id',ref,'/sources/'+sid+'/data/id',**extra)
for name in names(e):
add(name,'name',sid+':name',ref,'/sources/'+sid+'/data',**extra)
snake=re.sub(r'[^A-Za-z0-9]+','_',name).strip('_')
if snake and snake!=name:add(snake,'name',sid+':name',ref,'/sources/'+sid+'/data',**extra)
facts=context.case.get('runtime_facts',{}).get('facts',[])
for index,fact in enumerate(facts):
if fact.get('name') in config.context_fact_names:
add(fact.get('value'),'context','fact:'+fact['id'],context.case_ref,
'/runtime_facts/facts/'+str(index)+'/value')
value=fact.get('value',{})
if fact.get('name')!='project_query' or not isinstance(value,dict):continue
capture_ref=value.get('capture_ref')
if capture_ref not in reader.files:continue
captured=read_json(reader,capture_ref,'project-capture',1024*1024)
if captured.get('query')!=value.get('query'):continue
for rule in config.entity_tables:
if value.get('query',{}).get('kind')!='database.rows' or value['query'].get('table')!=rule['table']:continue
for row_index,item in enumerate(captured.get('result',{}).get('items',[])):
row=item.get('row',{})
if not any(e.get('type')==rule['entity_type'] and str(e.get('id'))==str(row.get(rule['id_column'])) for e in [entities[s] for s in selected]):continue
for column in rule['script_columns']:
script=row.get(column)
if isinstance(script,str) and re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{2,199}',script):
add(script,'script','script:'+script,capture_ref,
'/result/items/'+str(row_index)+'/row/'+column,
entity_type=rule['entity_type'])
priority={'script':0,'name':1,'context':2,'id':3}
anchors.sort(key=lambda a:(priority[a['kind']],a['group'],a['term']))
return {'case_ref':context.case_ref,'case_sha256':hashlib.sha256(json_bytes(context.case)).hexdigest(),
'interpretation_context_ref':ref,
'interpretation_context_sha256':hashlib.sha256(json_bytes(interpreted)).hexdigest() if interpreted else None,
'entity_refs':selected,'ambiguous_identity':ambiguous,'anchors':anchors[:config.max_seed_terms],
'omitted_anchors':max(0,len(anchors)-config.max_seed_terms),'omitted_entities':omitted_entities}
def _prefixes(config,args):
scope=args.get('scope','code');requested=args['path_prefix']
configured={'code':config.code_prefixes,'schema':config.schema_prefixes,
'historical_sql':config.historical_prefixes}[scope]
if scope=='historical_sql' and not configured:raise ToolError('PROJECT_SOURCE_SCOPE_UNAVAILABLE')
effective=[]
for prefix in configured or ['']:
if not requested:effective.append(prefix)
elif requested.startswith(prefix):effective.append(requested)
elif prefix.startswith(requested):effective.append(prefix)
if not effective:raise ToolError('PROJECT_SOURCE_SCOPE_CONFLICT')
return list(dict.fromkeys(effective))
def classify(seed,text,config):
"""Scores order candidates, never express confidence in the bug cause."""
if not seed['term'].isdigit():
if seed['kind']=='script':return 100,'exact_script_anchor',True
if seed['kind']=='symbol':return 60,'requested_symbol',True
if seed['kind']=='registration':return 40,'registration_of_selected_candidate',True
if seed['kind']=='bound_symbol':return 50,'symbol_from_typed_binding_candidate',True
return (25 if seed['kind']=='name' else 15),seed['kind']+'_context',False
match=re.search(r'\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*'+re.escape(seed['term'])+r'(?:[uUlL]{0,3})(?![\w.])',text)
if match:
types={typ for typ,markers in config.type_markers.items() if any(
re.search(r'(?:^|_)'+re.escape(marker.upper())+r'(?:_|$)',match[1].upper()) for marker in markers)}
if seed.get('entity_type') and types=={seed['entity_type']}:
return 50,'typed_identifier_binding_candidate',True
if types:return 0,'different_or_ambiguous_identifier_namespace',False
return 0,'unbound_numeric_occurrence',False
def analyze(analysis,args,seed_context=None):
analysis.validate(args)
config,provider=analysis.config,analysis.provider
if args.get('entity_refs') and seed_context is None:raise ToolError('PROJECT_SOURCE_CONTEXT_REQUIRED')
seed_context=seed_context or {'anchors':[],'omitted_anchors':0}
seeds=copy.deepcopy(seed_context['anchors'])
root={'term':args['text'],'kind':'id' if args['text'].isdigit() else 'symbol',
'group':'requested_term','source_ref':None,'json_pointer':None}
matches=[s for s in seeds if s['term'].casefold()==args['text'].casefold() and s['kind'] in {'script','name','context'}]
if matches and not args['text'].isdigit():
match=min(matches,key=lambda s:{'script':0,'name':1,'context':2}[s['kind']])
root={**match,'term':args['text']}
# The root numeric seed may inherit a unique verified namespace, not a guessed one.
types={s.get('entity_type') for s in seeds if s['term']==args['text'] and s.get('entity_type')}
if len(types)==1:root['entity_type']=next(iter(types))
seeds=[root]+[s for s in seeds if not (s['term']==root['term'] and s['kind']=='id')]
unique={}
for seed in seeds:unique.setdefault((seed['term'].casefold(),seed['kind'],seed['group']),seed)
seeds=list(unique.values())
def priority(s):
if s['kind']=='script':return 0
if s['kind']=='symbol':return 1
if s['kind']=='name' and not set(s.get('roles',[])) & set(config.focus_roles):return 1
if s['group']=='requested_term':return 2
return {'context':3,'name':4,'id':5}.get(s['kind'],6)
seeds.sort(key=priority)
if len(seeds)>config.max_seed_terms:seed_context={**seed_context,'omitted_anchors':seed_context.get('omitted_anchors',0)+len(seeds)-config.max_seed_terms}
seeds=seeds[:config.max_seed_terms]
if root not in seeds:
seeds=seeds[:config.max_seed_terms-1]+[root]
prefixes=_prefixes(config,args);scope=args.get('scope','code')
extensions=[e for e in config.extensions if e.lower()!='sql'] if scope=='code' else ['sql']
deadline=time.monotonic()+provider.limits.timeout_seconds
probe=copy.copy(provider);searches=[];hits={};limits=set();pending=list(seeds);seen=set()
queued={s['term'].casefold() for s in seeds};content_cache={};input_bytes=0
if seed_context.get('omitted_anchors') or seed_context.get('omitted_entities'):limits.add('seed_limit')
def tick():
seconds=deadline-time.monotonic()
if seconds<=0:raise ToolError('PROJECT_SOURCE_TIMEOUT')
probe.limits=replace(provider.limits,timeout_seconds=max(1,math.ceil(seconds)),
max_search_bytes=max(1,provider.limits.max_search_bytes//config.max_searches))
def content(path):
nonlocal input_bytes
if path not in content_cache:
if len(content_cache)>=config.max_files:return None
tick();text,origin=probe._read(path);input_bytes+=origin['size']
if input_bytes>provider.limits.max_search_bytes:raise ToolError('PROJECT_SOURCE_INPUT_LIMIT')
content_cache[path]=(text,origin)
return content_cache[path]
def follow(term,kind,seed,path,line):
if term.casefold() in queued:return
if len(queued)>=config.max_seed_terms+config.max_searches*config.max_matches:
limits.add('candidate_limit');return
queued.add(term.casefold())
derived={'term':term,'kind':kind,'group':seed['group'],
'source_ref':path,'json_pointer':None,'derived_from_line':line}
if kind=='bound_symbol':pending.insert(0,derived)
else:pending.append(derived)
while pending and len(searches)<config.max_searches:
seed=pending.pop(0)
for prefix in prefixes:
key=(seed['term'].casefold(),prefix)
if key in seen:continue
if len(searches)>=config.max_searches:limits.add('followup_search_limit');break
seen.add(key);tick()
query={'kind':'source.search','text':seed['term'],'path_prefix':prefix,'case_sensitive':False,
'offset':0,'limit':min(config.max_candidate_matches,provider.limits.max_rows),
'_extensions':extensions,'_numeric_token':seed['term'].isdigit(),
'_exclude_prefixes':list(config.historical_prefixes) if scope!='historical_sql' else []}
result=probe.query(query);sid='S'+str(len(searches)+1)
searches.append({'id':sid,'seed':seed,'query':query,'coverage':result['coverage'],
'truncated':result['truncated'],'returned_matches':len(result['items']),
'skipped_files':result.get('skipped_files',[])})
if result['truncated']:limits.add('match_limit')
if result.get('skipped_files'):limits.add('incomplete_search')
for hit in result['items']:
score,reason,strong=classify(seed,hit['text'],config)
key=(hit['path'],hit['line']);record=hits.setdefault(key,{**hit,'signals':[]})
record['signals'].append({'search_id':sid,'group':seed['group'],'score':score,'reason':reason,'strong':strong})
if strong and seed['term'].isdigit():
alias=re.search(r'\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*'+re.escape(seed['term'])+r'\b',hit['text'])
if alias:follow(alias[1],'bound_symbol',seed,hit['path'],hit['line'])
if strong and seed['kind']!='registration' and not any(seed['term'].startswith(p) for p in config.registration_prefixes):
data=content(hit['path'])
if data:
found=0
for number,line in enumerate(data[0].splitlines(),1):
if number%256==0:tick()
for marker in config.registration_prefixes:
for match in re.finditer(r'\b('+re.escape(marker)+r'[A-Za-z0-9_]*)\s*\(',line):
follow(match[1],'registration',seed,hit['path'],number);found+=1
if found>=config.max_matches:limits.add('registration_limit');break
if any((s['term'].casefold(),p) not in seen for s in pending for p in prefixes):limits.add('followup_search_limit')
files={}
for hit in hits.values():
row=files.setdefault(hit['path'],{'hits':[],'groups':{},'strong':False})
row['hits'].append(hit)
for signal in hit['signals']:
row['groups'][signal['group']]=max(row['groups'].get(signal['group'],0),signal['score'])
row['strong']|=signal['strong']
locations={s['term'].casefold() for s in seeds if s['kind']=='context'
and s['term'].casefold() in {name.casefold() for name in config.location_paths}}
for path,row in files.items():
owners={name.casefold() for name,ps in config.location_paths.items() if any(path.startswith(p) for p in ps)}
row['other_location']=bool(locations and owners and not owners & locations)
row['score']=sum(row['groups'].values())
row['tier']=('supporting_other_location' if row['other_location'] else 'primary_candidate') if row['strong'] else ('supporting_context' if row['score'] else 'weak_numeric')
ordered=sorted(files,key=lambda p:(files[p]['tier']=='weak_numeric',files[p]['other_location'],-files[p]['score'],p))
items=[];used_files=used_lines=used_blocks=read_bytes=weak_count=0
for path in ordered:
row=files[path];first=row['hits'][0]
meta={k:first[k] for k in ('path','git_blob','sha256','size')}
weak=row['tier']=='weak_numeric'
summaries=[]
for hit in sorted(row['hits'],key=lambda h:h['line']):
if weak and weak_count>=config.max_weak_hits:limits.add('weak_summary_limit');break
summaries.append({'line':hit['line'],'text':hit['text'][:240],'signals':hit['signals'],
'text_truncated':hit.get('text_truncated',False) or len(hit['text'])>240})
if weak:weak_count+=1
if not weak:
for summary in summaries:summary.pop('text',None);summary.pop('text_truncated',None)
if weak:
if summaries:items.append({**meta,'tier':row['tier'],'ranking_score':0,'hits':summaries,'blocks':[],
'notice':'Numeric occurrence has no verified matching identity binding; not expanded or followed.'})
continue
if used_files>=config.max_files:limits.add('file_limit');continue
data=content(path)
if data is None:limits.add('file_limit');continue
text,origin=data;used_files+=1
lines=text.splitlines();ranges=[]
for hit in sorted(row['hits'],key=lambda h:h['line']):
start,end=max(1,hit['line']-config.context_lines),min(len(lines),hit['line']+config.context_lines)
if ranges and start<=ranges[-1][1]+1:ranges[-1][1]=max(ranges[-1][1],end)
else:ranges.append([start,end])
# Registrations are collected only inside a relevant implementation file.
# They do not cause every other registration in a global loader to be expanded.
registration=[]
if not any(s['kind']=='symbol' and any(s['term'].startswith(p) for p in config.registration_prefixes) for s in seeds):
for number,line in enumerate(lines,1):
if number%256==0:tick()
if len(registration)>=config.max_matches:limits.add('registration_limit');break
for prefix in config.registration_prefixes:
if re.search(r'\b'+re.escape(prefix)+r'[A-Za-z0-9_]*\s*\(',line):registration.append(number)
for number in registration:ranges.append([max(1,number-config.context_lines),min(len(lines),number+config.context_lines)])
merged=[]
for start,end in sorted(ranges):
if merged and start<=merged[-1][1]+1:merged[-1][1]=max(merged[-1][1],end)
else:merged.append([start,end])
blocks=[]
for start,end in merged:
count=min(end-start+1,config.max_total_lines-used_lines)
if used_blocks>=config.max_blocks:limits.add('block_limit');break
if count<=0:limits.add('line_limit');break
if count<end-start+1:limits.add('line_limit')
segment=lines[start-1:start-1+count];truncated=[start+i for i,t in enumerate(segment) if len(t)>2048]
if truncated:limits.add('line_text_limit')
blocks.append({'start_line':start,'lines':[t[:2048] for t in segment],'truncated_lines':truncated})
used_lines+=count;used_blocks+=1
items.append({**origin,'tier':row['tier'],'ranking_score':row['score'],'hits':summaries,'blocks':blocks,
'registration_candidate_lines':registration,'total_lines':len(lines),
'omitted_context_lines':sum(end-start+1 for start,end in merged)-sum(len(b['lines']) for b in blocks)})
tick()
result={'items':items,'searches':searches,'limits_reached':sorted(limits),
'selection':{'scope':scope,'path_prefixes':prefixes,'excluded_prefixes':list(config.historical_prefixes) if scope!='historical_sql' else [],
'primary_files':[p for p in ordered if files[p]['tier']=='primary_candidate'],
'supporting_files':[p for p in ordered if files[p]['tier'].startswith('supporting')],
'weak_numeric_files':sum(files[p]['tier']=='weak_numeric' for p in ordered),
'notice':'No strong primary candidate in examined results is a valid outcome; it is not global absence.'},
'coverage':'partial_configured_source_context' if limits else 'complete_configured_source_context',
'truncated':bool(limits),'interpretation':
'Heuristic context candidates only. Numeric frequency and multiple unrelated IDs never establish identity. '
'Code/schema/history scopes and lexical token forms are limited; no complete call graph, '
'runtime conclusion or global absence proof. Empty primary selection is allowed.'}
if len(json_bytes(result))>provider.limits.max_result_bytes:raise ToolError('PROJECT_RESULT_LIMIT')
return result
+173
View File
@@ -0,0 +1,173 @@
import copy
import json
from pathlib import Path
import subprocess
from types import SimpleNamespace
import pytest
from viano_bug_analysis.tools import ToolError
from viano_bug_analysis.tools.project_query import ProjectResearch
from viano_bug_analysis.tools.repository import GitSourceProvider
from viano_bug_analysis.tools.source_relevance import case_seeds
from test_case_builder import case_setup
from test_investigation_tools import tool_case
@pytest.fixture
def source(tmp_path):
root=tmp_path/'repo'
for d in ['src','sql/base','sql/old']: (root/d).mkdir(parents=True,exist_ok=True)
(root/'src/a_noise.cpp').write_text('\n'.join('SPELL_NOISE_'+str(i)+' = 77;' for i in range(20))+'\nvoid AddSC_noise() {}\n',encoding='utf-8')
(root/'src/target.cpp').write_text('enum { NPC_TARGET = 77 };\nvoid npc_target() { summon(NPC_TARGET); }\nvoid AddSC_target() { npc_target(); }\n',encoding='utf-8')
(root/'src/loader.cpp').write_text('void AddSC_target();\nvoid load() { AddSC_target(); }\nvoid AddSC_unrelated();\n',encoding='utf-8')
(root/'src/coordinates.cpp').write_text('float x = 77.123;\nint larger = 177;\nint random = 88;\n',encoding='utf-8')
(root/'src/other_area.cpp').write_text('void npc_cross_zone() {}\n',encoding='utf-8')
(root/'src/zone.cpp').write_text('void npc_local() {}\n',encoding='utf-8')
(root/'sql/base/world.sql').write_text('CREATE TABLE target (id int);\n',encoding='utf-8')
(root/'sql/old/noise.sql').write_text('INSERT INTO spell VALUES (77);\n',encoding='utf-8')
def git(*args):return subprocess.run(['git','-C',str(root),*args],check=True,capture_output=True).stdout.decode().strip()
git('init');git('add','.');git('-c','user.name=Test','-c','user.email=test@example.invalid','commit','-m','fixture')
return GitSourceProvider(root,git('rev-parse','HEAD'),'https://example.invalid/repo')
def service(source,**settings):
return ProjectResearch({},source=source,source_analysis={'protocol':2,'max_searches':8,
'code_prefixes':['src/'],'schema_prefixes':['sql/base/'],'historical_prefixes':['sql/old/'],
'registration_prefixes':['AddSC_'],'type_markers':{'actor':['NPC'],'effect':['SPELL']},**settings})
def query(text='77',**extra):return {'kind':'source.context','text':text,'path_prefix':'',**extra}
def seeds(*anchors):return {'anchors':list(anchors),'omitted_anchors':0}
def anchor(term,kind='id',group='N0001:id',**extra):
return {'term':term,'kind':kind,'group':group,'source_ref':'case/case.json',
'json_pointer':'/entities/0','entity_type':'actor',**extra}
def test_typed_collision_not_frequency_selects_implementation(source):
r=json.loads(service(source).collect(query(),source_context=seeds(anchor('77'))))['result']
items={i['path']:i for i in r['items']}
assert items['src/target.cpp']['tier']=='primary_candidate'
assert items['src/a_noise.cpp']['tier']=='weak_numeric' and items['src/a_noise.cpp']['blocks']==[]
assert 'src/coordinates.cpp' not in items
assert all('different_or_ambiguous_identifier_namespace' in [s['reason'] for s in h['signals']] for h in items['src/a_noise.cpp']['hits'])
assert 'AddSC_noise' not in [s['query']['text'] for s in r['searches']]
assert {'NPC_TARGET','AddSC_target'} <= {s['query']['text'] for s in r['searches']}
def test_untyped_numbers_do_not_promote_or_expand_even_when_only_hits(source):
r=json.loads(service(source).collect(query()))['result']
assert r['selection']['primary_files']==[]
assert all(i['tier']=='weak_numeric' and not i['blocks'] for i in r['items'])
assert len(r['searches'])==1
assert len([h for i in r['items'] for h in i['hits']])<=8
def test_names_variants_are_not_independent_corroboration(source):
r=json.loads(service(source).collect(query('77'),source_context=seeds(
anchor('npc_target','name','same_name'),anchor('NPC_TARGET','name','same_name'))))['result']
item=next(i for i in r['items'] if i['path']=='src/target.cpp')
assert item['tier']=='supporting_context' and item['ranking_score']==25
r=json.loads(service(source).collect(query('npc_target'),source_context=seeds(
anchor('npc_target','name','same_name'))))['result']
assert r['selection']['primary_files']==[]
def test_known_other_area_remains_usable_context(source):
r=json.loads(service(source,location_paths={'Home':['src/zone.cpp'],'Elsewhere':['src/other_area.cpp']}).collect(
query('npc_local'),source_context=seeds(anchor('npc_cross_zone','script','script'),
anchor('Home','context','place'))))['result']
items={i['path']:i for i in r['items']}
assert items['src/other_area.cpp']['tier']=='supporting_other_location'
assert items['src/other_area.cpp']['blocks']
assert 'src/other_area.cpp' in r['selection']['supporting_files']
def test_scopes_do_not_silently_mix_code_schema_and_history(source):
s=service(source)
assert not any(i['path'].endswith('.sql') for i in json.loads(s.collect(query()))['result']['items'])
r=json.loads(s.collect(query('CREATE TABLE',scope='schema')))['result']
assert [i['path'] for i in r['items']]==['sql/base/world.sql']
r=json.loads(s.collect(query(scope='historical_sql')))['result']
assert [i['path'] for i in r['items']]==['sql/old/noise.sql']
with pytest.raises(ToolError,match='SCOPE_CONFLICT'):s.collect(query(path_prefix='sql/old/'))
with pytest.raises(ToolError):s.collect(query(scope='all'))
def test_new_args_require_a_case_and_do_not_accept_model_fabricated_anchors(source):
s=service(source)
with pytest.raises(ToolError,match='CONTEXT_REQUIRED'):s.collect(query(entity_refs=['N0001']))
with pytest.raises(ToolError):s.collect({**query(),'anchors':[anchor('npc_fake','script')]})
assert ProjectResearch({},source=source,source_analysis={}).source_context.catalog()['version']==1
with pytest.raises(ToolError):ProjectResearch({},source=source,source_analysis={'code_prefixes':['src/']})
def test_selection_limits_are_explicit_and_source_worktree_is_ignored(source):
(source.path/'src/target.cpp').write_text('wrong mutable worktree\n',encoding='utf-8')
r=json.loads(service(source,max_total_lines=1).collect(query(),source_context=seeds(anchor('77'))))['result']
assert r['truncated'] and 'line_limit' in r['limits_reached']
assert any(i.get('omitted_context_lines',0)>0 for i in r['items'])
assert 'wrong mutable' not in json.dumps(r)
@pytest.mark.parametrize('settings',[{'type_markers':{'actor':['.*']}},{'protocol':True},
{'code_prefixes':['../escape']},{'max_seed_terms':0},{'location_paths':{'Home':['C:/bad']}}])
def test_invalid_adapter_rules_fail_controlled(source,settings):
with pytest.raises(ToolError):service(source,**settings)
def test_case_collector_names_roles_scripts_and_provenance(source,monkeypatch):
import viano_bug_analysis.case.contracts as contracts
sources={'N0001':{'kind':'entity','data':{'type':'actor','id':'77','name':'Target actor','roles':['affected_subject']}},
'N0002':{'kind':'entity','data':{'type':'effect','id':'88','name':'Related quest','roles':['selected_focus']}}}
captured={'query':{'kind':'database.rows','table':'actors'},'result':{'items':[{'row':{'entry':77,'ScriptName':'npc_target'}}]}}
fact={'id':'F0001','name':'project_query','value':{'capture_ref':'research/capture.json',**captured}}
case={'runtime_facts':{'facts':[fact,{'id':'F0002','name':'location_name','value':'Home'}]}}
ctx=SimpleNamespace(case=case,case_ref='case/case.json',reader=SimpleNamespace(
manifest={'artifacts':{'interpretation_context':'case/context.json'}},files=['research/capture.json']))
monkeypatch.setattr(contracts,'read_json',lambda r,ref,*args: {'sources':sources} if ref=='case/context.json' else captured)
s=service(source,focus_roles=['selected_focus'],context_fact_names=['location_name'],entity_tables=[
{'table':'actors','entity_type':'actor','id_column':'entry','script_columns':['ScriptName']}])
c=case_seeds(ctx,query(),s.source_context.config)
assert c['entity_refs']==['N0001','N0002']
assert any(a['term']=='npc_target' and a['source_ref']=='research/capture.json' for a in c['anchors'])
assert any(a['term']=='Home' for a in c['anchors'])
assert all(a['source_ref'] and a['json_pointer'] for a in c['anchors'])
with pytest.raises(ToolError,match='ENTITY_REF'):case_seeds(ctx,query(entity_refs=['N9999']),s.source_context.config)
def test_ambiguous_id_does_not_inherit_a_namespace(source,monkeypatch):
import viano_bug_analysis.case.contracts as contracts
sources={sid:{'kind':'entity','data':{'type':typ,'id':'77','roles':[]}}
for sid,typ in [('N0001','actor'),('N0002','effect')]}
monkeypatch.setattr(contracts,'read_json',lambda *args:{'sources':sources})
ctx=SimpleNamespace(case={},case_ref='case/case.json',reader=SimpleNamespace(
manifest={'artifacts':{'interpretation_context':'case/context.json'}}))
s=service(source);c=case_seeds(ctx,query(),s.source_context.config)
assert c['ambiguous_identity'] and all(a.get('entity_type') is None for a in c['anchors'])
r=json.loads(s.collect(query(),source_context=c))['result']
assert r['selection']['primary_files']==[]
def test_v2_fixture_loop_captures_case_seeds_and_resumes(source,tool_case,tmp_path):
from test_case_interpreter import gateway
from test_investigation_hybrid import HybridAdapter
from test_investigator import run
from viano_bug_analysis.analysis import Investigator,InvestigationConfig
from viano_bug_analysis.tools import InvestigationToolRouter
from viano_bug_analysis.package import PackageReader,PackageValidator
from viano_bug_analysis.lifecycle import Workspace,CheckpointStore
s=service(source)
with PackageReader(tool_case[1]) as reader:s.project=reader.manifest['project']
adapter=HybridAdapter();adapter.bootstrap.change=lambda r,c:r.update(next={'kind':'tool','operation':'project.query','arguments':query('npc_target')})
router=InvestigationToolRouter(project_research=s)
first=run(Investigator(tool_case[0],gateway(adapter),InvestigationConfig(lean_loop=True,lean_protocol=2,lean_checkpoint_steps=0),router=router),steps=3)
portable=CheckpointStore(Workspace(tmp_path/'portable'));portable.intake(first.package)
final=run(Investigator(portable,gateway(adapter),router=router))
assert final.status=='COMPLETED' and PackageValidator().validate(final.package).valid
from viano_bug_analysis.llm.cache_measurement import measure_package
assert measure_package(final.package)['all_exactly_reconstructed']