Document controlled rejection V1 baseline

This commit is contained in:
2026-08-20 13:19:54 +02:00
parent 0d4b426021
commit 8ca62fbd92
5 changed files with 264 additions and 0 deletions
+62
View File
@@ -1962,6 +1962,68 @@ rejection, production integration or beginning cross-pattern reconciliation.
Artifacts are preserved under
`artifacts/experiments/negative_act_form_v0/20260820_qwen35_9b_single_run/`.
## EXP-0036 — Controlled Rejection Derivation V1
Status: Experimental; architecturally unsuccessful
Date: 2026-08-20
This isolated experiment followed the failed binary rejection baseline
(EXP-0034) and successful Negative Act Form classification (EXP-0035). Its V1
hypothesis was to classify the negative act first, resolve its local target in
a separate semantic call, and only then derive `explicitly_rejected`
deterministically. It did not modify either predecessor or any accepted Stage-2
pattern, and it has no production integration.
The target recognizer emitted exactly `candidate_observation_id`,
`target_observation_id`, and `normalized_target_text`. Only
`explicit_non_pursuit` was deterministically eligible. Personal preference,
recommendation, temporary non-action, and `none` could never derive rejection,
even with a valid target. Provenance, local membership, ordering, non-empty
target text, and strict non-normative output were additional gates.
`explicitly_rejected` means rejected by the cited evidence only, not a final
decision, topic outcome, permanent state, or closure.
The run reused five exact accepted Negative Act Form outputs and made three new
Negative Act calls plus eight target-resolution calls. All calls used
`qwen3.5:9B`, temperature 0, `think=false`, `num_ctx=16384`,
`num_predict=1024`, no retries, voting, or prompt changes.
| Case | Negative Act expected / actual | Target result | Verdict |
| --- | --- | --- | --- |
| CR-01 | `explicit_non_pursuit` / same | Model returned the string `"null"` as an unknown ID; self-contained target was not linked | FAIL |
| CR-02 | `explicit_non_pursuit` / same | `obs_1`, external solution and continuation preserved | PASS |
| CR-03 | `personal_preference` / same | `obs_1`; eligibility gate prevented rejection | PASS |
| CR-04 | `recommendation` / same | `obs_1`; eligibility gate prevented rejection | PASS |
| CR-05 | `temporary_non_action` / same | `obs_1`; eligibility gate prevented rejection | PASS |
| CR-06 | `none` / same | Null target; final non-rejection was correct, but expected local target was unresolved | FAIL |
| CR-07 | `explicit_non_pursuit` / same | `obs_1`; real-plant and pressure-test scope survived, but normalization remained proposition-like | PARTIAL |
| CR-08 | `explicit_non_pursuit` / same | `obs_1`; real-plant scope preserved and Technikum alternative excluded | PASS |
Result: five PASS, one PARTIAL, two FAIL. All eight Negative Act forms were
correct. There were no false-positive rejections: the valid targets in CR-03,
CR-04, and CR-05 could not override their ineligible forms. There was one
false-negative rejection, CR-01, caused by invalid target output. Target
resolution missed two expected links (invalid CR-01 and null CR-06), so the
wrong/unresolved-target count was two. CR-06 exposed a strategy flaw: a
non-eligible Negative Act form should not be required to pass target resolution
when it cannot derive rejection. Qualifier-loss count was zero. CR-08 isolated
the positive alternative successfully. No individual owner, responsibility,
decision, outcome, topic-closure, or LLM-emitted rejection status appeared.
There were 3 new Negative Act calls, 8 target calls, 5 accepted classification
reuses, zero technical call failures, and one structural target-validation
failure. Aggregate runner time was 11.951 seconds.
Conclusion: negative-act-form gating is promising and successfully contains
the semantic false positives that defeated EXP-0034, but the experiment is not
architecturally successful. The current target-resolution strategy failed the
required self-contained positive CR-01 and unnecessarily evaluated the
ineligible CR-06 path; it is not reliable enough for rejection derivation.
Artifacts are preserved under
`artifacts/experiments/controlled_rejection_v1/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_rejection_v1 import main
if __name__ == "__main__": raise SystemExit(main())
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Isolated controlled rejection derivation V1 experiment."""
from __future__ import annotations
import argparse, json, time
from pathlib import Path
from typing import Any
from .experiment_h import DEFAULT_ENDPOINT, DEFAULT_MODEL, DerivationValidationError, OBSERVATION_KEYS, call_ollama
from .experiment_negative_act import build_prompt as build_negative_prompt, parse_model_json, validate_classification
SCHEMA_VERSION="experimental-controlled-rejection-v1"
TARGET_KEYS={"candidate_observation_id","target_observation_id","normalized_target_text"}
FORBIDDEN={"rejection_form","negative_act_form","explicitly_rejected","status","decision","outcome","topic_status","closed","responsible_person","responsibility","owner","requested_actor","action_item","protocol_category","confidence","relation","relations","graph","unresolved_issue"}
PROMPT="""Resolve only the concrete local action or option referred to by the candidate negative act. The candidate is {candidate}. Choose only a supplied observation ID. Use the same observation for a self-contained target. If no unique local target exists, return null for both target fields. Preserve source-language meaning and material scope such as purpose and location. Preserve continuation when non-pursuit concerns continuing something. Ignore any separate positive alternative. Do not classify the negative act and do not output rejection, status, decision, outcome, responsibility, protocol concepts, confidence, relations, or graphs. Return exactly JSON with candidate_observation_id, target_observation_id, normalized_target_text and no other fields.\nObservations:\n{observations}"""
def _keys(v,r,loc):
if not isinstance(v,dict): raise DerivationValidationError(f"{loc} must be an object")
if set(v)!=r: raise DerivationValidationError(f"{loc} keys invalid: missing={sorted(r-set(v))}, unknown={sorted(set(v)-r)}")
def _text(v,loc):
if not isinstance(v,str) or not v.strip(): raise DerivationValidationError(f"{loc} must be non-empty")
return v.strip()
def _forbidden(v,loc="output"):
if isinstance(v,dict):
bad=FORBIDDEN & set(v)
if bad: raise DerivationValidationError(f"{loc} contains forbidden fields: {sorted(bad)}")
for k,x in v.items(): _forbidden(x,f"{loc}.{k}")
elif isinstance(v,list):
for i,x in enumerate(v): _forbidden(x,f"{loc}[{i}]")
def validate_observations(obs):
if not isinstance(obs,list) or not obs: raise DerivationValidationError("observations must be non-empty")
ids=set(); evid=set()
for i,o in enumerate(obs):
_keys(o,OBSERVATION_KEYS,f"observations[{i}]"); oid=_text(o["observation_id"],"observation_id"); eid=_text(o["evidence_id"],"evidence_id")
if oid in ids or eid in evid: raise DerivationValidationError("provenance must be unique")
ids.add(oid); evid.add(eid); _text(o["content"],"content"); _text(o["speaker"],"speaker")
return ids
def validate_target(data,obs):
ids=validate_observations(obs); _forbidden(data); _keys(data,TARGET_KEYS,"target output")
candidate=_text(data["candidate_observation_id"],"candidate_observation_id")
if candidate not in ids: raise DerivationValidationError("unknown candidate observation")
target=data["target_observation_id"]; normalized=data["normalized_target_text"]
if target is None:
if normalized is not None: raise DerivationValidationError("null target requires null text")
else:
target=_text(target,"target_observation_id")
if target not in ids: raise DerivationValidationError("unknown target observation")
_text(normalized,"normalized_target_text")
return data
def build_target_prompt(case):
validate_observations(case["observations"])
return PROMPT.format(candidate=case["expected"]["candidate_observation_id"],observations=json.dumps(case["observations"],ensure_ascii=False,indent=2))
def derive(obs,negative,target):
ids=validate_observations(obs); validate_classification(negative,obs); validate_target(target,obs)
candidate=negative["observation_id"]
if target["candidate_observation_id"]!=candidate: raise DerivationValidationError("candidate outputs disagree")
positions={o["observation_id"]:i for i,o in enumerate(obs)}; tid=target["target_observation_id"]
gates={"negative_act_valid":True,"eligible_explicit_non_pursuit":negative["negative_act_form"]=="explicit_non_pursuit","candidate_exists":candidate in ids,"target_valid":True,"target_present":tid is not None,"target_exists":tid in ids if tid else False,"target_not_after_candidate":positions[tid]<=positions[candidate] if tid else False,"provenance_valid_unique":True,"normalized_target_nonempty":bool(target["normalized_target_text"] and target["normalized_target_text"].strip()),"same_isolated_case":tid in ids if tid else False,"no_forbidden_fields":True}
established=all(gates.values())
result=None
if established:
byid={o["observation_id"]:o for o in obs}
result={"rejection_id":"rejection_1","content":target["normalized_target_text"].strip(),"status":"explicitly_rejected","support":{"target":{"observation_id":tid,"evidence_id":byid[tid]["evidence_id"]},"negative_act":{"observation_id":candidate,"evidence_id":byid[candidate]["evidence_id"]}}}
return {"gates":gates,"derived_result":result}
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")
return data["cases"]
def _concepts(text,groups):
folded=(text or "").casefold(); return all(any(x.casefold() in folded for x in g) for g in groups)
def evaluate(case,negative,target,derivation):
e=case["expected"]; text=target["normalized_target_text"]
form=negative["negative_act_form"]==e["negative_act_form"]; target_ok=target["target_observation_id"]==e["target_observation_id"]
action=_concepts(text,e["action_concepts"]); material=_concepts(text,e["material_concepts"]); forbidden=any(x.casefold() in (text or "").casefold() for x in e["forbidden_concepts"])
final=(derivation["derived_result"] is not None)==e["explicitly_rejected"]
label="PASS" if form and target_ok and action and material and not forbidden and final else ("PARTIAL" if form and target_ok and material and not forbidden and final else "FAIL")
return {"case_id":case["case_id"],"classification":label,"expected_negative_act_form":e["negative_act_form"],"actual_negative_act_form":negative["negative_act_form"],"expected_target_observation_id":e["target_observation_id"],"actual_target_observation_id":target["target_observation_id"],"normalized_target_text":text,"normalized_action_correct":action,"material_scope_preserved":material,"alternative_absorbed":forbidden,"final_rejection_correct":final}
def _write(p,v): p.write_text(json.dumps(v,ensure_ascii=False,indent=2)+"\n",encoding="utf-8")
def run(args):
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})
evals=[]; naf_calls=target_calls=technical_failures=structural_failures=0; start=time.perf_counter()
for case in cases:
d=args.output/case["case_id"].lower(); d.mkdir(); obs=case["observations"]; _write(d/"v3_style_input_observations.json",obs)
try:
source=case["negative_act_source"]
if source=="live":
np=build_negative_prompt(case); (d/"negative_act_prompt.txt").write_text(np,encoding="utf-8"); raw,nmeta=call_ollama(args.endpoint,args.model,np,args.timeout,args.num_ctx,args.num_predict); naf_calls+=1; (d/"negative_act_raw_response.txt").write_text(raw+"\n",encoding="utf-8"); negative=parse_model_json(raw)
_write(d/"negative_act_ollama_metadata.json",nmeta); _write(d/"negative_act_source.json",{"kind":"live_call"})
else:
sd=args.negative_act_artifacts/source.lower(); accepted=json.loads((sd/"v3_style_input_observations.json").read_text());
if accepted!=obs: raise DerivationValidationError(f"{source} observations do not exactly match")
negative=json.loads((sd/"parsed_semantic_classification.json").read_text()); _write(d/"negative_act_source.json",{"kind":"accepted_artifact_reuse","case_id":source,"path":str(sd)})
validate_classification(negative,obs); _write(d/"negative_act_classification.json",negative)
tp=build_target_prompt(case); (d/"target_prompt.txt").write_text(tp,encoding="utf-8"); traw,tmeta=call_ollama(args.endpoint,args.model,tp,args.timeout,args.num_ctx,args.num_predict); target_calls+=1; (d/"target_raw_response.txt").write_text(traw+"\n",encoding="utf-8"); _write(d/"target_ollama_metadata.json",tmeta); target=parse_model_json(traw); _write(d/"target_recognition.json",target); validate_target(target,obs)
derivation=derive(obs,negative,target); _write(d/"deterministic_gate_results.json",derivation["gates"]); _write(d/"final_derived_result.json",derivation["derived_result"]); ev=evaluate(case,negative,target,derivation)
_write(d/"structural_validation.json",{"valid":True})
except Exception as exc:
structural_failures+=1; ev={"case_id":case["case_id"],"classification":"FAIL","technical_or_validation_failure":str(exc)}; _write(d/"structural_validation.json",{"valid":False,"error":str(exc)})
_write(d/"evaluation.json",ev); evals.append(ev)
summary={"experiment":"controlled_rejection_v1","model":args.model,"negative_act_llm_call_count":naf_calls,"reused_negative_act_count":len(cases)-naf_calls,"target_resolution_llm_call_count":target_calls,"technical_failed_call_count":technical_failures,"structural_validation_failure_count":structural_failures,"runtime_seconds":round(time.perf_counter()-start,3),"counts":{x:sum(e["classification"]==x for e in evals) for x in ["PASS","PARTIAL","FAIL"]},"evaluations":evals}; _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("--negative-act-artifacts",type=Path,default=Path("artifacts/experiments/negative_act_form_v0/20260820_qwen35_9b_single_run")); 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); args=p.parse_args(); print(json.dumps(run(args),ensure_ascii=False,indent=2)); return 0
@@ -0,0 +1,13 @@
{
"schema_version": "experimental-controlled-rejection-v1",
"cases": [
{"case_id":"CR-01","description":"self-contained non-pursuit","negative_act_source":"NA-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}],"expected":{"negative_act_form":"explicit_non_pursuit","candidate_observation_id":"obs_1","target_observation_id":"obs_1","action_concepts":[["Schlummer"],["Zusammenarbeit","arbeiten"],["fortsetzen","weiter"]],"material_concepts":[],"forbidden_concepts":[],"explicitly_rejected":true}},
{"case_id":"CR-02","description":"paired non-pursuit","negative_act_source":"live","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}],"expected":{"negative_act_form":"explicit_non_pursuit","candidate_observation_id":"obs_2","target_observation_id":"obs_1","action_concepts":[["externe Lösung"],["weiterverfolgen","weiter verfolgen"]],"material_concepts":[],"forbidden_concepts":[],"explicitly_rejected":true}},
{"case_id":"CR-03","description":"personal preference","negative_act_source":"NA-03","observations":[{"observation_id":"obs_1","evidence_id":"e1","content":"Martin: Wir könnten die reale Anlage für den Versuch nutzen.","speaker":"Martin","named_person":null,"addressee":null},{"observation_id":"obs_2","evidence_id":"e2","content":"Martin: Ich würde das nicht machen.","speaker":"Martin","named_person":null,"addressee":null}],"expected":{"negative_act_form":"personal_preference","candidate_observation_id":"obs_2","target_observation_id":"obs_1","action_concepts":[["reale Anlage"],["Versuch"],["nutzen"]],"material_concepts":[],"forbidden_concepts":[],"explicitly_rejected":false}},
{"case_id":"CR-04","description":"recommendation","negative_act_source":"NA-04","observations":[{"observation_id":"obs_1","evidence_id":"e1","content":"Martin: Wir könnten die reale Anlage verwenden.","speaker":"Martin","named_person":null,"addressee":null},{"observation_id":"obs_2","evidence_id":"e2","content":"Martin: Ich würde eher davon abraten.","speaker":"Martin","named_person":null,"addressee":null}],"expected":{"negative_act_form":"recommendation","candidate_observation_id":"obs_2","target_observation_id":"obs_1","action_concepts":[["reale Anlage"],["verwenden","nutzen"]],"material_concepts":[],"forbidden_concepts":[],"explicitly_rejected":false}},
{"case_id":"CR-05","description":"temporary non-action","negative_act_source":"NA-05","observations":[{"observation_id":"obs_1","evidence_id":"e1","content":"Martin: Wir könnten die Waschstufe einbauen.","speaker":"Martin","named_person":null,"addressee":null},{"observation_id":"obs_2","evidence_id":"e2","content":"Martin: Das machen wir erstmal noch nicht.","speaker":"Martin","named_person":null,"addressee":null}],"expected":{"negative_act_form":"temporary_non_action","candidate_observation_id":"obs_2","target_observation_id":"obs_1","action_concepts":[["Waschstufe"],["einbauen"]],"material_concepts":[],"forbidden_concepts":[],"explicitly_rejected":false}},
{"case_id":"CR-06","description":"concern","negative_act_source":"NA-06","observations":[{"observation_id":"obs_1","evidence_id":"e1","content":"Martin: Wir könnten das neue Material einsetzen.","speaker":"Martin","named_person":null,"addressee":null},{"observation_id":"obs_2","evidence_id":"e2","content":"Martin: Das wäre kritisch.","speaker":"Martin","named_person":null,"addressee":null}],"expected":{"negative_act_form":"none","candidate_observation_id":"obs_2","target_observation_id":"obs_1","action_concepts":[["neue Material","neues Material"],["einsetzen"]],"material_concepts":[],"forbidden_concepts":[],"explicitly_rejected":false}},
{"case_id":"CR-07","description":"scoped explicit rejection","negative_act_source":"live","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}],"expected":{"negative_act_form":"explicit_non_pursuit","candidate_observation_id":"obs_2","target_observation_id":"obs_1","action_concepts":[["Anlage"],["nutzen"]],"material_concepts":[["real"],["Druckversuch"]],"forbidden_concepts":[],"explicitly_rejected":true}},
{"case_id":"CR-08","description":"rejection plus alternative","negative_act_source":"live","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}],"expected":{"negative_act_form":"explicit_non_pursuit","candidate_observation_id":"obs_2","target_observation_id":"obs_1","action_concepts":[["Versuch"],["durchführen"]],"material_concepts":[["real"],["Anlage"]],"forbidden_concepts":["Technikum"],"explicitly_rejected":true}}
]
}
@@ -0,0 +1,82 @@
import copy, json, unittest
from pathlib import Path
from src.meeting_lab.controlled_semantic_derivation.experiment_h import DerivationValidationError
from src.meeting_lab.controlled_semantic_derivation.experiment_rejection_v1 import (
build_target_prompt, derive, load_cases, validate_target,
)
CASES=load_cases(Path("tests/gold/controlled_rejection_v1/cases.json"))
BY_ID={c["case_id"]:c for c in CASES}
def negative(case, form=None):
return {"observation_id":case["expected"]["candidate_observation_id"],"negative_act_form":form or case["expected"]["negative_act_form"],"normalized_action_text":None if (form or case["expected"]["negative_act_form"])=="none" else "semantische Aktion"}
def target(case, oid=None, text="konkrete Zielhandlung"):
return {"candidate_observation_id":case["expected"]["candidate_observation_id"],"target_observation_id":oid or case["expected"]["target_observation_id"],"normalized_target_text":text}
class ControlledRejectionV1Tests(unittest.TestCase):
def test_positive_forms_derive_and_provenance_survives(self):
for cid in ("CR-01","CR-02","CR-07","CR-08"):
c=BY_ID[cid]; out=derive(c["observations"],negative(c),target(c))
self.assertEqual(out["derived_result"]["status"],"explicitly_rejected")
self.assertEqual(out["derived_result"]["support"]["target"]["evidence_id"],"e1")
self.assertNotIn("responsible_person",json.dumps(out["derived_result"]))
def test_noneligible_form_never_derives_even_with_target(self):
for form in ["personal_preference","recommendation","temporary_non_action","none"]:
c=BY_ID["CR-03"]; self.assertIsNone(derive(c["observations"],negative(c,form),target(c))["derived_result"])
def test_missing_target_prevents_derivation(self):
c=BY_ID["CR-02"]; t=target(c); t.update(target_observation_id=None,normalized_target_text=None)
self.assertIsNone(derive(c["observations"],negative(c),t)["derived_result"])
def test_unknown_ids_rejected(self):
for field in ["candidate_observation_id","target_observation_id"]:
c=BY_ID["CR-02"]; t=target(c); t[field]="obs_unknown"
with self.assertRaises(DerivationValidationError): validate_target(t,c["observations"])
def test_target_after_candidate_cannot_derive(self):
c=copy.deepcopy(BY_ID["CR-02"]); n={"observation_id":"obs_1","negative_act_form":"explicit_non_pursuit","normalized_action_text":"x"}; t={"candidate_observation_id":"obs_1","target_observation_id":"obs_2","normalized_target_text":"x"}
self.assertIsNone(derive(c["observations"],n,t)["derived_result"])
def test_same_observation_target_allowed(self):
c=BY_ID["CR-01"]; self.assertIsNotNone(derive(c["observations"],negative(c),target(c))["derived_result"])
def test_duplicate_provenance_rejected(self):
for field in ["observation_id","evidence_id"]:
c=copy.deepcopy(BY_ID["CR-02"]); c["observations"][1][field]=c["observations"][0][field]
with self.assertRaises(DerivationValidationError): derive(c["observations"],negative(BY_ID["CR-02"]),target(BY_ID["CR-02"]))
def test_target_text_constraints(self):
c=BY_ID["CR-02"]
with self.assertRaises(DerivationValidationError): validate_target(target(c,text=""),c["observations"])
t=target(c); t["target_observation_id"]=None
with self.assertRaises(DerivationValidationError): validate_target(t,c["observations"])
def test_null_target_accepts_only_null_text(self):
c=BY_ID["CR-02"]; t=target(c); t.update(target_observation_id=None,normalized_target_text=None)
self.assertEqual(validate_target(t,c["observations"]),t)
def test_forbidden_and_unknown_fields_rejected(self):
for extra in [{"status":"rejected"},{"nested":{"decision":True}},{"extra":1}]:
c=BY_ID["CR-02"]; t=target(c); t.update(extra)
with self.assertRaises(DerivationValidationError): validate_target(t,c["observations"])
def test_candidate_outputs_must_agree(self):
c=BY_ID["CR-02"]; t=target(c); t["candidate_observation_id"]="obs_1"
with self.assertRaises(DerivationValidationError): derive(c["observations"],negative(c),t)
def test_scope_and_alternative_fixture_contract(self):
self.assertLessEqual({"real","Druckversuch"},{x for group in BY_ID["CR-07"]["expected"]["material_concepts"] for x in group})
self.assertEqual(BY_ID["CR-08"]["expected"]["forbidden_concepts"],["Technikum"])
def test_target_prompt_is_semantic_only_and_fixed(self):
prompt=build_target_prompt(BY_ID["CR-08"])
self.assertIn("Ignore any separate positive alternative",prompt)
self.assertIn("Do not classify the negative act",prompt)
def test_accepted_negative_act_inputs_are_exactly_reused(self):
root=Path("artifacts/experiments/negative_act_form_v0/20260820_qwen35_9b_single_run")
for cid,nid in (("CR-01","NA-01"),("CR-03","NA-03"),("CR-04","NA-04"),("CR-05","NA-05"),("CR-06","NA-06")):
accepted=json.loads((root/nid.lower()/"v3_style_input_observations.json").read_text())
self.assertEqual(BY_ID[cid]["observations"],accepted)