Document target normalization V0 experiment

This commit is contained in:
2026-08-20 14:29:53 +02:00
parent 7fa771a7e4
commit 3918c0b1c4
5 changed files with 197 additions and 0 deletions
+49
View File
@@ -2138,6 +2138,55 @@ normalization, but not deriving rejection or integrating a new pipeline.
Artifacts are preserved under
`artifacts/experiments/target_resolution_v1_diagnostic/20260820_qwen35_9b_single_run/`.
## EXP-0039 — Target Normalization V0
Status: Experimental; normalization improved but incomplete
Date: 2026-08-20
Target Resolution V1 established correct linkage for all four narrow cases and
eliminated structural ID failures with deterministic self-linkage, closed ID
lists, and true JSON Schemas. Its remaining failures were normalization-only.
This isolated follow-up therefore accepted candidate and target IDs as fixed
input and tested only reconstruction of the positive German action meaning. It
contains no target selection, Negative Act classification, eligibility logic,
rejection derivation, or production integration.
The strict output schema contained exactly `candidate_observation_id`,
`target_observation_id`, and `normalized_target_text`. Both IDs were constrained
to their supplied values with JSON Schema `const`; normalized text was a
non-empty string and null was disallowed. The one fixed prompt required removal
of negative polarity, preservation of action, continuation, material scope and
source language, and exclusion of separate alternatives.
| Case | Actual normalized target | Verdict |
| --- | --- | --- |
| TN-01 | `Mit Dr. Schlummer zusammenarbeiten` | FAIL: positive polarity and collaboration survived, but continuation was lost |
| TN-02 | `externe Lösung weiterverfolgen` | PASS |
| TN-03 | `reale Anlage für den Druckversuch nutzen` | PASS |
| TN-04 | `Versuch in der realen Anlage durchführen` | PASS; Technikum alternative excluded |
Result: three PASS, zero PARTIAL, one FAIL. All four outputs passed strict
schema validation and copied both fixed IDs exactly, so changed-ID count was
zero. Polarity-error count was zero: even TN-01 removed rejection and negation.
Action/continuation-loss count was one (TN-01); material purpose/location
scope-loss count was zero; alternative-absorption count was zero. There was no
unsupported strengthening or normative leakage.
Configuration: exactly four `qwen3.5:9B` calls, temperature 0,
`think=false`, true JSON Schema, `num_ctx=16384`, `num_predict=1024`, no
retries, voting, or prompt tuning. Aggregate runner time was 5.066 seconds.
Conclusion: isolating normalization solved the polarity and scoped-action
failures seen in Target Resolution V1 for three of four cases, including exact
pressure-test scope and alternative isolation. Continuation semantics remain
unreliable in the self-contained collaboration case, so Target Normalization
V0 does not meet its full acceptance criterion. The result does not justify
rejection derivation or production integration.
Artifacts are preserved under
`artifacts/experiments/target_normalization_v0/20260820_qwen35_9b_single_run/`.
## EXP-0026 — Topic-oriented Discussion Subject reconstruction V2 prototype
Date: 2026-08-11
@@ -0,0 +1,7 @@
#!/usr/bin/env python3
import sys
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path: sys.path.insert(0,str(ROOT))
from src.meeting_lab.controlled_semantic_derivation.experiment_target_normalization import main
if __name__=="__main__": raise SystemExit(main())
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Target Normalization V0: reconstruct target text with fixed linkage."""
from __future__ import annotations
import argparse,json,time
from pathlib import Path
from typing import Any,Callable
import requests
from .experiment_h import DEFAULT_ENDPOINT,DEFAULT_MODEL,DerivationValidationError,OBSERVATION_KEYS
SCHEMA_VERSION="experimental-target-normalization-v0"
OUTPUT_KEYS={"candidate_observation_id","target_observation_id","normalized_target_text"}
LINK_KEYS={"candidate_observation_id","target_observation_id"}
FORBIDDEN={"negative_act_form","rejection_form","explicitly_rejected","status","decision","outcome","topic_status","responsible_person","responsibility","owner","requested_actor","action_item","protocol_category","confidence","relation","relations","graph","unresolved_issue"}
PROMPT="""The candidate and target observation IDs below are already resolved. Copy both IDs exactly; do not perform target selection. Reconstruct only the concrete POSITIVE action or option meaning targeted by the negative act. Remove rejection and negation polarity while preserving the underlying positive action. Preserve German source language, material qualifiers, purpose, location, named people, and continuation. Exclude separate positive alternatives. Do not summarize the discussion or infer rejection, decision, outcome, status, responsibility, ownership, protocol relevance, confidence, relations, graphs, or topic state. Return only the JSON-Schema-conforming object; null is not permitted.\n\nExample A observations: [{{"observation_id":"obs_a","content":"Mit Frau Beispiel arbeiten wir nicht weiter."}}]\nFixed IDs: candidate=obs_a, target=obs_a\nOutput: {{"candidate_observation_id":"obs_a","target_observation_id":"obs_a","normalized_target_text":"Zusammenarbeit mit Frau Beispiel fortsetzen"}}\n\nExample B observations: [{{"observation_id":"obs_a","content":"Für den Druckversuch steht die reale Anlage zur Diskussion."}},{{"observation_id":"obs_b","content":"Die reale Anlage nutzen wir dafür nicht."}}]\nFixed IDs: candidate=obs_b, target=obs_a\nOutput: {{"candidate_observation_id":"obs_b","target_observation_id":"obs_a","normalized_target_text":"reale Anlage für den Druckversuch nutzen"}}\n\nFixed candidate_observation_id: {candidate}\nFixed target_observation_id: {target}\nV3-style observations:\n{observations}"""
def _keys(value,required,where):
if not isinstance(value,dict): raise DerivationValidationError(f"{where} must be an object")
if set(value)!=required: raise DerivationValidationError(f"{where} keys invalid: missing={sorted(required-set(value))}, unknown={sorted(set(value)-required)}")
def _text(value,where):
if not isinstance(value,str) or not value.strip(): raise DerivationValidationError(f"{where} must be non-empty")
return value.strip()
def _reject_forbidden(value,where="output"):
if isinstance(value,dict):
bad=FORBIDDEN & set(value)
if bad: raise DerivationValidationError(f"{where} contains forbidden fields: {sorted(bad)}")
for key,item in value.items(): _reject_forbidden(item,f"{where}.{key}")
elif isinstance(value,list):
for index,item in enumerate(value): _reject_forbidden(item,f"{where}[{index}]")
def validate_observations(observations):
if not isinstance(observations,list) or not observations: raise DerivationValidationError("observations must be non-empty")
ids=set(); evidence=set()
for index,item in enumerate(observations):
_keys(item,OBSERVATION_KEYS,f"observations[{index}]"); oid=_text(item["observation_id"],"observation_id"); eid=_text(item["evidence_id"],"evidence_id")
if oid in ids or eid in evidence: raise DerivationValidationError("observation/evidence provenance must be unique")
ids.add(oid); evidence.add(eid); _text(item["content"],"content"); _text(item["speaker"],"speaker")
return ids
def validate_linkage(case):
ids=validate_observations(case["observations"]); linkage=case["fixed_linkage"]; _keys(linkage,LINK_KEYS,"fixed_linkage")
for field in LINK_KEYS:
if _text(linkage[field],field) not in ids: raise DerivationValidationError(f"{field} is unknown")
return linkage
def output_schema(case):
link=validate_linkage(case)
return {"type":"object","additionalProperties":False,"required":["candidate_observation_id","target_observation_id","normalized_target_text"],"properties":{"candidate_observation_id":{"const":link["candidate_observation_id"]},"target_observation_id":{"const":link["target_observation_id"]},"normalized_target_text":{"type":"string","minLength":1}}}
def build_prompt(case):
link=validate_linkage(case)
return PROMPT.format(candidate=link["candidate_observation_id"],target=link["target_observation_id"],observations=json.dumps(case["observations"],ensure_ascii=False,indent=2))
def validate_output(data,case):
_reject_forbidden(data); _keys(data,OUTPUT_KEYS,"output"); link=validate_linkage(case)
if data["candidate_observation_id"]!=link["candidate_observation_id"]: raise DerivationValidationError("candidate ID changed")
if data["target_observation_id"]!=link["target_observation_id"]: raise DerivationValidationError("target ID changed")
_text(data["normalized_target_text"],"normalized_target_text"); return data
def build_payload(model,prompt,schema,num_ctx,num_predict): return {"model":model,"prompt":prompt,"think":False,"stream":False,"format":schema,"options":{"temperature":0,"num_ctx":num_ctx,"num_predict":num_predict}}
def call_schema(endpoint,model,prompt,schema,timeout,num_ctx,num_predict):
started=time.perf_counter(); response=requests.post(endpoint,json=build_payload(model,prompt,schema,num_ctx,num_predict),timeout=timeout); elapsed=time.perf_counter()-started; response.raise_for_status(); body=response.json(); raw=body.get("response")
if not isinstance(raw,str) or not raw.strip(): raise ValueError("Ollama returned no usable response")
return raw.strip(),{"model":body.get("model",model),"elapsed_seconds":round(elapsed,3),"total_duration_ns":body.get("total_duration"),"prompt_eval_count":body.get("prompt_eval_count"),"eval_count":body.get("eval_count"),"configuration":{"temperature":0,"think":False,"format":"json_schema_object","num_ctx":num_ctx,"num_predict":num_predict,"retries":0}}
def _concepts(text,groups):
folded=text.casefold(); return all(any(alias.casefold() in folded for alias in group) for group in groups)
def evaluate(case,output):
validate_output(output,case); expected=case["expected"]; text=output["normalized_target_text"]; folded=text.casefold(); action=_concepts(text,expected["action_concepts"]); scope=_concepts(text,expected["material_concepts"]); forbidden=[x for x in expected["forbidden_concepts"] if x.casefold() in folded]; positive=not any(x in forbidden for x in ("nicht","beenden")); german=any(x.casefold() in folded for x in expected["german_markers"]); alternative=not any(x.casefold() in folded for x in ("technikum","stattdessen")); strengthening=False
label="PASS" if positive and action and scope and german and alternative and not forbidden and not strengthening else "FAIL"
return {"case_id":case["case_id"],"classification":label,"expected_normalized_target_text":expected["normalized_target_text"],"actual_normalized_target_text":text,"positive_polarity_correct":positive,"action_semantics_preserved":action,"material_scope_preserved":scope,"source_language_preserved":german,"separate_alternative_excluded":alternative,"forbidden_semantics_present":forbidden,"unsupported_strengthening":strengthening,"normative_leakage":False,"candidate_id_unchanged":True,"target_id_unchanged":True}
def load_cases(path):
data=json.loads(path.read_text(encoding="utf-8")); _keys(data,{"schema_version","cases"},"fixture")
if data["schema_version"]!=SCHEMA_VERSION: raise DerivationValidationError("wrong schema version")
for case in data["cases"]: validate_linkage(case)
return data["cases"]
def _write(path,value): path.write_text(json.dumps(value,ensure_ascii=False,indent=2)+"\n",encoding="utf-8")
def run(args,caller:Callable=call_schema):
cases=load_cases(args.cases); args.output.mkdir(parents=True,exist_ok=False); _write(args.output/"gold_cases.json",{"schema_version":SCHEMA_VERSION,"cases":cases}); evaluations=[]; calls=failures=0; started=time.perf_counter()
for case in cases:
folder=args.output/case["case_id"].lower(); folder.mkdir(); _write(folder/"v3_style_input_observations.json",case["observations"]); _write(folder/"fixed_linkage.json",case["fixed_linkage"]); schema=output_schema(case); _write(folder/"ollama_json_schema.json",schema); prompt=build_prompt(case); (folder/"prompt.txt").write_text(prompt,encoding="utf-8")
try:
raw,metadata=caller(args.endpoint,args.model,prompt,schema,args.timeout,args.num_ctx,args.num_predict); calls+=1; (folder/"raw_model_response.txt").write_text(raw+"\n",encoding="utf-8"); _write(folder/"ollama_metadata.json",metadata); parsed=json.loads(raw); _write(folder/"parsed_response.json",parsed); validate_output(parsed,case); _write(folder/"structural_validation.json",{"valid":True}); _write(folder/"normalized_target_result.json",{"normalized_target_text":parsed["normalized_target_text"]}); evaluation=evaluate(case,parsed)
except Exception as exc:
failures+=1; _write(folder/"structural_validation.json",{"valid":False,"error":str(exc)}); evaluation={"case_id":case["case_id"],"classification":"FAIL","error":str(exc),"normative_leakage":False}
_write(folder/"evaluation.json",evaluation); evaluations.append(evaluation)
summary={"experiment":"target_normalization_v0","model":args.model,"llm_call_count":calls,"structural_validation_failure_count":failures,"runtime_seconds":round(time.perf_counter()-started,3),"counts":{x:sum(e["classification"]==x for e in evaluations) for x in ["PASS","PARTIAL","FAIL"]},"evaluations":evaluations}; _write(args.output/"summary.json",summary); return summary
def main():
p=argparse.ArgumentParser(); p.add_argument("cases",type=Path); p.add_argument("-o","--output",type=Path,required=True); p.add_argument("--model",default=DEFAULT_MODEL); p.add_argument("--endpoint",default=DEFAULT_ENDPOINT); p.add_argument("--timeout",type=int,default=300); p.add_argument("--num-ctx",type=int,default=16384); p.add_argument("--num-predict",type=int,default=1024); print(json.dumps(run(p.parse_args()),ensure_ascii=False,indent=2)); return 0
@@ -0,0 +1,9 @@
{
"schema_version":"experimental-target-normalization-v0",
"cases":[
{"case_id":"TN-01","observations":[{"observation_id":"obs_1","evidence_id":"e1","content":"Martin: Mit Dr. Schlummer arbeiten wir nicht weiter.","speaker":"Martin","named_person":"Dr. Schlummer","addressee":null}],"fixed_linkage":{"candidate_observation_id":"obs_1","target_observation_id":"obs_1"},"expected":{"normalized_target_text":"Zusammenarbeit mit Dr. Schlummer fortsetzen","action_concepts":[["Zusammenarbeit","arbeiten"],["Schlummer"],["fortsetzen","weiter"]],"material_concepts":[],"forbidden_concepts":["nicht","beenden"],"german_markers":["Zusammenarbeit","arbeiten","fortsetzen"]}},
{"case_id":"TN-02","observations":[{"observation_id":"obs_1","evidence_id":"e1","content":"Martin: Eine Möglichkeit wäre, die externe Lösung weiterzuverfolgen.","speaker":"Martin","named_person":null,"addressee":null},{"observation_id":"obs_2","evidence_id":"e2","content":"Martin: Das verfolgen wir nicht weiter.","speaker":"Martin","named_person":null,"addressee":null}],"fixed_linkage":{"candidate_observation_id":"obs_2","target_observation_id":"obs_1"},"expected":{"normalized_target_text":"externe Lösung weiterverfolgen","action_concepts":[["externe Lösung"],["weiterverfolgen","weiter verfolgen"]],"material_concepts":[],"forbidden_concepts":["nicht"],"german_markers":["Lösung","verfolgen"]}},
{"case_id":"TN-03","observations":[{"observation_id":"obs_1","evidence_id":"e1","content":"Martin: Für den Druckversuch steht die reale Anlage zur Diskussion.","speaker":"Martin","named_person":null,"addressee":null},{"observation_id":"obs_2","evidence_id":"e2","content":"Martin: Die reale Anlage nutzen wir dafür nicht.","speaker":"Martin","named_person":null,"addressee":null}],"fixed_linkage":{"candidate_observation_id":"obs_2","target_observation_id":"obs_1"},"expected":{"normalized_target_text":"reale Anlage für den Druckversuch nutzen","action_concepts":[["Anlage"],["nutzen"]],"material_concepts":[["real"],["Druckversuch"]],"forbidden_concepts":["nicht","zur Diskussion"],"german_markers":["Anlage","Druckversuch","nutzen"]}},
{"case_id":"TN-04","observations":[{"observation_id":"obs_1","evidence_id":"e1","content":"Martin: Wir könnten den Versuch in der realen Anlage durchführen.","speaker":"Martin","named_person":null,"addressee":null},{"observation_id":"obs_2","evidence_id":"e2","content":"Martin: Das machen wir nicht; wir testen stattdessen im Technikum.","speaker":"Martin","named_person":null,"addressee":null}],"fixed_linkage":{"candidate_observation_id":"obs_2","target_observation_id":"obs_1"},"expected":{"normalized_target_text":"Versuch in der realen Anlage durchführen","action_concepts":[["Versuch"],["durchführen"]],"material_concepts":[["real"],["Anlage"]],"forbidden_concepts":["nicht","Technikum","stattdessen"],"german_markers":["Versuch","Anlage","durchführen"]}}
]
}
@@ -0,0 +1,51 @@
import argparse,copy,json,tempfile,unittest
from pathlib import Path
from unittest.mock import Mock
import src.meeting_lab.controlled_semantic_derivation.experiment_target_normalization as module
from src.meeting_lab.controlled_semantic_derivation.experiment_h import DerivationValidationError
CASES=module.load_cases(Path("tests/gold/target_normalization_v0/cases.json")); BY={c["case_id"]:c for c in CASES}
def output(case,text=None):
link=case["fixed_linkage"]; return {"candidate_observation_id":link["candidate_observation_id"],"target_observation_id":link["target_observation_id"],"normalized_target_text":text or case["expected"]["normalized_target_text"]}
class TargetNormalizationTests(unittest.TestCase):
def test_exact_id_copying_accepted(self):
for case in CASES: self.assertEqual(module.validate_output(output(case),case),output(case))
def test_changed_candidate_rejected(self):
case=BY["TN-02"]; data=output(case); data["candidate_observation_id"]="obs_1"
with self.assertRaises(DerivationValidationError): module.validate_output(data,case)
def test_changed_target_rejected(self):
case=BY["TN-02"]; data=output(case); data["target_observation_id"]="obs_2"
with self.assertRaises(DerivationValidationError): module.validate_output(data,case)
def test_empty_and_null_text_rejected(self):
case=BY["TN-01"]
for value in ("",None):
data=output(case); data["normalized_target_text"]=value
with self.assertRaises(DerivationValidationError): module.validate_output(data,case)
def test_unknown_and_forbidden_fields_rejected(self):
case=BY["TN-01"]
for extra in ({"extra":1},{"status":"x"},{"nested":{"decision":True}}):
data=output(case); data.update(extra)
with self.assertRaises(DerivationValidationError): module.validate_output(data,case)
def test_true_schema_fixes_both_ids_and_disallows_null(self):
case=BY["TN-02"]; schema=module.output_schema(case); self.assertEqual(schema["properties"]["candidate_observation_id"]["const"],"obs_2"); self.assertEqual(schema["properties"]["target_observation_id"]["const"],"obs_1"); self.assertEqual(schema["properties"]["normalized_target_text"]["type"],"string"); self.assertFalse(schema["additionalProperties"])
def test_payload_uses_schema_object(self):
schema=module.output_schema(BY["TN-01"]); payload=module.build_payload("qwen3.5:9B","p",schema,16384,1024); self.assertIs(payload["format"],schema); self.assertIsInstance(payload["format"],dict)
def test_duplicate_ids_and_evidence_rejected(self):
for field in ("observation_id","evidence_id"):
case=copy.deepcopy(BY["TN-02"]); case["observations"][1][field]=case["observations"][0][field]
with self.assertRaises(DerivationValidationError): module.validate_linkage(case)
def test_prompt_is_fixed_normalization_only(self):
first=module.build_prompt(BY["TN-01"]); second=module.build_prompt(BY["TN-02"]); self.assertIn("do not perform target selection",first); self.assertIn("concrete POSITIVE action",first); self.assertEqual(first.split("Fixed candidate_observation_id:")[0],second.split("Fixed candidate_observation_id:")[0])
def test_no_target_selection_or_rejection_derivation_exists(self):
self.assertFalse(hasattr(module,"select_target")); self.assertFalse(hasattr(module,"derive")); self.assertNotIn("explicitly_rejected",module.OUTPUT_KEYS); self.assertNotIn("status",module.OUTPUT_KEYS)
def test_artifacts_preserve_fixed_linkage(self):
case=BY["TN-01"]; fixture={"schema_version":module.SCHEMA_VERSION,"cases":[case]}; caller=Mock(return_value=(json.dumps(output(case)),{"model":"qwen3.5:9B"}))
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp); path=root/"cases.json"; path.write_text(json.dumps(fixture)); out=root/"out"; args=argparse.Namespace(cases=path,output=out,endpoint="x",model="qwen3.5:9B",timeout=1,num_ctx=16384,num_predict=1024); summary=module.run(args,caller); self.assertEqual(summary["llm_call_count"],1); self.assertEqual(json.loads((out/"tn-01"/"fixed_linkage.json").read_text()),case["fixed_linkage"]); self.assertTrue((out/"tn-01"/"normalized_target_result.json").exists())
def test_negative_polarity_fails_evaluation(self):
case=BY["TN-01"]; self.assertEqual(module.evaluate(case,output(case,"Mit Dr. Schlummer arbeiten wir nicht weiter"))["classification"],"FAIL")
def test_material_scope_and_alternative_contract(self):
self.assertEqual(module.evaluate(BY["TN-03"],output(BY["TN-03"]))["classification"],"PASS"); self.assertEqual(module.evaluate(BY["TN-04"],output(BY["TN-04"]))["classification"],"PASS")
if __name__=="__main__": unittest.main()