S0-R1-C5.1 Hypothesis Authority Semantic JIT Citation Split

#79
Files changed (2) hide show
  1. core/generation_runner.py +189 -40
  2. core/pipeline_agent.py +166 -40
core/generation_runner.py CHANGED
@@ -1047,6 +1047,36 @@ class GenerationRunner:
1047
  "or change the construct pairing to fit observed results. The contract is result-independent."
1048
  )
1049
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1050
  @classmethod
1051
  def _hypothesis_contract_violations(
1052
  cls, text: str, ledger: dict, *, section_name: str = "", require_all: bool = False
@@ -1081,45 +1111,43 @@ class GenerationRunner:
1081
  "reason": "Ordinal hypothesis aliases can silently reorder the canonical H1-Hn identity.",
1082
  "repair_instruction": "Refer to hypotheses only by their frozen H1-Hn identifiers.",
1083
  })
1084
- definition_scope = bool(re.search(r"Hypothesis Development|Integrated Research Model", str(section_name or ""), flags=re.I))
1085
- if definition_scope:
1086
- all_constructs = {
1087
- re.sub(r"\s+", " ", str(item.get(key) or "")).strip()
1088
- for item in items for key in ("predictor", "outcome")
1089
- if str(item.get(key) or "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
  }
1091
- for start, end, sentence in SSciPipelineAgent._sentence_spans(value):
1092
- ids = list(dict.fromkeys(
1093
- f"H{n}" for n in re.findall(r"\bH(\d{1,2})\b", sentence, flags=re.I)
1094
- ))
1095
- # Collective references such as "H1-H3 are summarized below"
1096
- # are not definitions and must not be mistaken for remapping.
1097
- if len(ids) != 1:
1098
- continue
1099
- hid = ids[0]
1100
- item = expected.get(hid)
1101
- if not item:
1102
- continue
1103
- predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
1104
- outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
1105
- lower_sentence = re.sub(r"\s+", " ", sentence).casefold()
1106
- mentioned_constructs = {
1107
- term for term in all_constructs if term.casefold() in lower_sentence
1108
- }
1109
- # Validate pairing only when the sentence actually names one or
1110
- # more constructs. A bare cross-reference ("H1 is summarized")
1111
- # remains legal and cannot silently redefine the hypothesis.
1112
- if mentioned_constructs and predictor and outcome and (
1113
- predictor.casefold() not in lower_sentence or outcome.casefold() not in lower_sentence
1114
- ):
1115
- rows.append({
1116
- "type": "hypothesis_contract_pairing_mismatch",
1117
- "id": hid,
1118
- "sentence": re.sub(r"\s+", " ", sentence).strip()[:420],
1119
- "expected": f"{predictor} -> {outcome}",
1120
- "reason": "A canonical hypothesis label was attached to a different construct pairing.",
1121
- "repair_instruction": f"Define {hid} only for {predictor} and {outcome}, using association language.",
1122
- })
1123
  if require_all:
1124
  missing = sorted(set(expected) - observed)
1125
  if missing:
@@ -2448,6 +2476,15 @@ class GenerationRunner:
2448
  )
2449
  state["data_metadata"] = data_metadata
2450
 
 
 
 
 
 
 
 
 
 
2451
  self._qualitative_quote_sources = (
2452
  self._qualitative_quote_source_map(data_metadata) if mode == "asq" else {}
2453
  )
@@ -2522,12 +2559,24 @@ class GenerationRunner:
2522
  agent.stream_generate_research_logic(
2523
  topic, keywords, synthesis_result, refs_text,
2524
  data_evidence=planning_data_md, mode=mode,
 
2525
  )
2526
  )
2527
  agent.record_prewrite_stage("research_logic", prewrite_started, prewrite_calls)
2528
  research_logic_contract = re.sub(
2529
  r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
2530
  ).strip()
 
 
 
 
 
 
 
 
 
 
 
2531
  if not research_logic_contract:
2532
  raise RuntimeError("research_logic_contract_missing")
2533
  agent.global_memory["research_logic_contract"] = research_logic_contract
@@ -2536,8 +2585,13 @@ class GenerationRunner:
2536
  working_front = agent.generate_working_front_matter(
2537
  topic, keywords, research_logic_contract, synthesis_result,
2538
  data_evidence=planning_data_md, mode=mode,
 
2539
  )
2540
  working_abstract = str(working_front.get("abstract") or "").strip()
 
 
 
 
2541
  working_keywords = list(working_front.get("keywords") or [])
2542
  if not working_abstract or not working_keywords:
2543
  raise RuntimeError("working_front_matter_missing")
@@ -2545,6 +2599,10 @@ class GenerationRunner:
2545
  mode, agent, quant_engine, working_abstract, data_metadata,
2546
  section_name="Working Abstract",
2547
  )
 
 
 
 
2548
  if front_fact_rows:
2549
  feedback = self._section_retry_feedback(front_fact_rows)
2550
  logging.warning(
@@ -2554,12 +2612,21 @@ class GenerationRunner:
2554
  working_front = agent.generate_working_front_matter(
2555
  topic, keywords, research_logic_contract, synthesis_result,
2556
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
 
2557
  )
2558
  working_abstract = str(working_front.get("abstract") or "").strip()
 
 
 
 
2559
  working_keywords = list(working_front.get("keywords") or [])
2560
  _, remaining_front_rows = self._repair_validate_study_fact_contract(
2561
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
2562
  )
 
 
 
 
2563
  if remaining_front_rows:
2564
  raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining_front_rows[:3]}")
2565
  quality_diagnostics.append({
@@ -2757,6 +2824,18 @@ class GenerationRunner:
2757
  research_logic_contract = str(
2758
  state.get("research_logic_contract") or agent.global_memory.get("research_logic_contract") or ""
2759
  )
 
 
 
 
 
 
 
 
 
 
 
 
2760
  citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
2761
  citation_minimums = dict(state.get("citation_minimums") or {})
2762
  citation_reserve_targets = dict(state.get("citation_reserve_targets") or {})
@@ -2798,20 +2877,37 @@ class GenerationRunner:
2798
  working_abstract = str(state.get("working_abstract") or "").strip()
2799
  working_keywords = list(state.get("working_keywords") or [])
2800
  if working_abstract and not state.get("english_finalized"):
 
 
 
 
2801
  _, resumed_front_rows = self._repair_validate_study_fact_contract(
2802
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
2803
  )
 
 
 
 
2804
  if resumed_front_rows:
2805
  feedback = self._section_retry_feedback(resumed_front_rows)
2806
  working_front = agent.generate_working_front_matter(
2807
  topic, keywords, research_logic_contract, synthesis_result,
2808
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
 
2809
  )
2810
  candidate_abstract = str(working_front.get("abstract") or "").strip()
 
 
 
 
2811
  candidate_keywords = list(working_front.get("keywords") or [])
2812
  _, remaining = self._repair_validate_study_fact_contract(
2813
  mode, agent, quant_engine, candidate_abstract, data_metadata, section_name="Working Abstract",
2814
  )
 
 
 
 
2815
  if remaining:
2816
  raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
2817
  working_abstract = candidate_abstract
@@ -3010,25 +3106,43 @@ class GenerationRunner:
3010
  working_front = agent.generate_working_front_matter(
3011
  topic, keywords, research_logic_contract, synthesis_result,
3012
  data_evidence=planning_data_md, mode=mode,
 
3013
  )
3014
  working_abstract = str(working_front.get("abstract") or "").strip()
 
 
 
 
3015
  working_keywords = list(working_front.get("keywords") or [])
3016
  if not working_abstract or not working_keywords:
3017
  raise RuntimeError("working_front_matter_missing")
3018
  _, front_fact_rows = self._repair_validate_study_fact_contract(
3019
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
3020
  )
 
 
 
 
3021
  if front_fact_rows:
3022
  feedback = self._section_retry_feedback(front_fact_rows)
3023
  working_front = agent.generate_working_front_matter(
3024
  topic, keywords, research_logic_contract, synthesis_result,
3025
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
 
3026
  )
3027
  working_abstract = str(working_front.get("abstract") or "").strip()
 
 
 
 
3028
  working_keywords = list(working_front.get("keywords") or [])
3029
  _, remaining = self._repair_validate_study_fact_contract(
3030
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
3031
  )
 
 
 
 
3032
  if remaining:
3033
  raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
3034
  quality_diagnostics.append({
@@ -3414,6 +3528,16 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3414
  en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
3415
  en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
3416
  en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
 
 
 
 
 
 
 
 
 
 
3417
  if mode == "asq":
3418
  # Model prose has no authority over quote presence/count.
3419
  en_raw = self._strip_model_controlled_qualitative_quotes(en_raw, data_metadata)
@@ -3919,9 +4043,14 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3919
  # front matter deterministically. No body rebalance may occur afterward.
3920
  self._progress(92, "final_front_matter")
3921
  final_front = agent.generate_final_front_matter(
3922
- topic, keywords, body_only, data_evidence=user_data_md, mode=mode
 
3923
  )
3924
  final_abstract = str(final_front.get("abstract") or "").strip()
 
 
 
 
3925
  final_keywords = list(final_front.get("keywords") or [])
3926
  if not final_abstract or not final_keywords:
3927
  raise RuntimeError("final_front_matter_missing")
@@ -3929,6 +4058,10 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3929
  mode, agent, quant_engine, final_abstract, data_metadata,
3930
  section_name="Final Abstract",
3931
  )
 
 
 
 
3932
  if final_front_fact_rows:
3933
  feedback = self._section_retry_feedback(final_front_fact_rows)
3934
  logging.warning(
@@ -3938,9 +4071,13 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3938
  )
3939
  final_front = agent.generate_final_front_matter(
3940
  topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
3941
- validation_feedback=feedback,
3942
  )
3943
  final_abstract = str(final_front.get("abstract") or "").strip()
 
 
 
 
3944
  final_keywords = list(final_front.get("keywords") or [])
3945
  if not final_abstract or not final_keywords:
3946
  raise RuntimeError("final_front_matter_missing_after_retry")
@@ -3948,6 +4085,10 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3948
  mode, agent, quant_engine, final_abstract, data_metadata,
3949
  section_name="Final Abstract",
3950
  )
 
 
 
 
3951
  if remaining_final_fact_rows:
3952
  state["final_abstract_retry_exhausted"] = {
3953
  "attempts": 2,
@@ -3973,6 +4114,14 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3973
  state.pop("final_abstract_retry_exhausted", None)
3974
  full_draft_en = replace_abstract(full_draft_en, final_abstract)
3975
  full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
 
 
 
 
 
 
 
 
3976
  body_only, _reference_tail = agent.validate_reference_tail(
3977
  full_draft_en, minimum_references=final_reference_count
3978
  )
 
1047
  "or change the construct pairing to fit observed results. The contract is result-independent."
1048
  )
1049
 
1050
+ @staticmethod
1051
+ def _hypothesis_memory_text(ledger: dict) -> str:
1052
+ """Render the sole AMJ H1-Hn memory from the frozen ledger."""
1053
+ lines = []
1054
+ for item in (dict(ledger or {}).get("items") or []):
1055
+ if not isinstance(item, dict):
1056
+ continue
1057
+ hid = str(item.get("hypothesis_id") or "").strip().upper()
1058
+ predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
1059
+ outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
1060
+ if hid and predictor and outcome:
1061
+ lines.append(f"{hid}: {predictor} is associated with {outcome}.")
1062
+ return "\n".join(lines)
1063
+
1064
+ @staticmethod
1065
+ def _normalize_hypothesis_ordinal_aliases(text: str, ledger: dict) -> str:
1066
+ """Map safe ordinal aliases to frozen H-identifiers without semantic rewrite."""
1067
+ value = str(text or "")
1068
+ items = [item for item in (dict(ledger or {}).get("items") or []) if isinstance(item, dict)]
1069
+ if not items:
1070
+ return value
1071
+ words = ["first", "second", "third", "fourth", "fifth", "sixth"]
1072
+ for index, item in enumerate(items[: len(words)]):
1073
+ hid = str(item.get("hypothesis_id") or "").strip().upper()
1074
+ if not hid:
1075
+ continue
1076
+ word = words[index]
1077
+ value = re.sub(rf"\b(?:the\s+)?{word}\s+hypothesis\b", hid, value, flags=re.I)
1078
+ return value
1079
+
1080
  @classmethod
1081
  def _hypothesis_contract_violations(
1082
  cls, text: str, ledger: dict, *, section_name: str = "", require_all: bool = False
 
1111
  "reason": "Ordinal hypothesis aliases can silently reorder the canonical H1-Hn identity.",
1112
  "repair_instruction": "Refer to hypotheses only by their frozen H1-Hn identifiers.",
1113
  })
1114
+ all_constructs = {
1115
+ re.sub(r"\s+", " ", str(item.get(key) or "")).strip()
1116
+ for item in items for key in ("predictor", "outcome")
1117
+ if str(item.get(key) or "").strip()
1118
+ }
1119
+ for start, end, sentence in SSciPipelineAgent._sentence_spans(value):
1120
+ ids = list(dict.fromkeys(
1121
+ f"H{n}" for n in re.findall(r"\bH(\d{1,2})\b", sentence, flags=re.I)
1122
+ ))
1123
+ # Collective references such as "H1-H3 are summarized below"
1124
+ # are not definitions and must not be mistaken for remapping.
1125
+ if len(ids) != 1:
1126
+ continue
1127
+ hid = ids[0]
1128
+ item = expected.get(hid)
1129
+ if not item:
1130
+ continue
1131
+ predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
1132
+ outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
1133
+ lower_sentence = re.sub(r"\s+", " ", sentence).casefold()
1134
+ mentioned_constructs = {
1135
+ term for term in all_constructs if term.casefold() in lower_sentence
1136
  }
1137
+ # Pairing identity is global, not limited to the theory-definition
1138
+ # sections. Require at least two named constructs so a bare reference
1139
+ # such as "H1 was supported" cannot be misclassified as remapping.
1140
+ if len(mentioned_constructs) >= 2 and predictor and outcome and (
1141
+ predictor.casefold() not in lower_sentence or outcome.casefold() not in lower_sentence
1142
+ ):
1143
+ rows.append({
1144
+ "type": "hypothesis_contract_pairing_mismatch",
1145
+ "id": hid,
1146
+ "sentence": re.sub(r"\s+", " ", sentence).strip()[:420],
1147
+ "expected": f"{predictor} -> {outcome}",
1148
+ "reason": "A canonical hypothesis label was attached to a different construct pairing.",
1149
+ "repair_instruction": f"Define {hid} only for {predictor} and {outcome}, using association language.",
1150
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1151
  if require_all:
1152
  missing = sorted(set(expected) - observed)
1153
  if missing:
 
2476
  )
2477
  state["data_metadata"] = data_metadata
2478
 
2479
+ hypothesis_ledger = dict(data_metadata.get("hypothesis_ledger") or {}) if mode == "amj" else {}
2480
+ hypothesis_contract = self._hypothesis_contract_directive(hypothesis_ledger)
2481
+ if hypothesis_ledger:
2482
+ # C5.1 single authority: legacy prose extraction may not redefine AMJ
2483
+ # H1-Hn once the result-independent ledger exists. Downstream memory is
2484
+ # populated deterministically from that ledger and survives checkpoints.
2485
+ agent.global_memory["hypotheses"] = self._hypothesis_memory_text(hypothesis_ledger)
2486
+ agent.global_memory["hypothesis_contract_locked"] = True
2487
+
2488
  self._qualitative_quote_sources = (
2489
  self._qualitative_quote_source_map(data_metadata) if mode == "asq" else {}
2490
  )
 
2559
  agent.stream_generate_research_logic(
2560
  topic, keywords, synthesis_result, refs_text,
2561
  data_evidence=planning_data_md, mode=mode,
2562
+ hypothesis_contract=hypothesis_contract,
2563
  )
2564
  )
2565
  agent.record_prewrite_stage("research_logic", prewrite_started, prewrite_calls)
2566
  research_logic_contract = re.sub(
2567
  r"<think>.*?</think>", "", research_logic_response, flags=re.DOTALL
2568
  ).strip()
2569
+ if hypothesis_ledger:
2570
+ research_logic_contract = self._normalize_hypothesis_ordinal_aliases(
2571
+ research_logic_contract, hypothesis_ledger
2572
+ )
2573
+ logic_hypothesis_rows = self._hypothesis_contract_violations(
2574
+ research_logic_contract, hypothesis_ledger, section_name="Research Logic Contract"
2575
+ )
2576
+ if logic_hypothesis_rows:
2577
+ raise RuntimeError(
2578
+ f"research_logic_hypothesis_contract_invalid:{logic_hypothesis_rows[:3]}"
2579
+ )
2580
  if not research_logic_contract:
2581
  raise RuntimeError("research_logic_contract_missing")
2582
  agent.global_memory["research_logic_contract"] = research_logic_contract
 
2585
  working_front = agent.generate_working_front_matter(
2586
  topic, keywords, research_logic_contract, synthesis_result,
2587
  data_evidence=planning_data_md, mode=mode,
2588
+ hypothesis_contract=hypothesis_contract,
2589
  )
2590
  working_abstract = str(working_front.get("abstract") or "").strip()
2591
+ if hypothesis_ledger:
2592
+ working_abstract = self._normalize_hypothesis_ordinal_aliases(
2593
+ working_abstract, hypothesis_ledger
2594
+ )
2595
  working_keywords = list(working_front.get("keywords") or [])
2596
  if not working_abstract or not working_keywords:
2597
  raise RuntimeError("working_front_matter_missing")
 
2599
  mode, agent, quant_engine, working_abstract, data_metadata,
2600
  section_name="Working Abstract",
2601
  )
2602
+ if hypothesis_ledger:
2603
+ front_fact_rows.extend(self._hypothesis_contract_violations(
2604
+ working_abstract, hypothesis_ledger, section_name="Working Abstract"
2605
+ ))
2606
  if front_fact_rows:
2607
  feedback = self._section_retry_feedback(front_fact_rows)
2608
  logging.warning(
 
2612
  working_front = agent.generate_working_front_matter(
2613
  topic, keywords, research_logic_contract, synthesis_result,
2614
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
2615
+ hypothesis_contract=hypothesis_contract,
2616
  )
2617
  working_abstract = str(working_front.get("abstract") or "").strip()
2618
+ if hypothesis_ledger:
2619
+ working_abstract = self._normalize_hypothesis_ordinal_aliases(
2620
+ working_abstract, hypothesis_ledger
2621
+ )
2622
  working_keywords = list(working_front.get("keywords") or [])
2623
  _, remaining_front_rows = self._repair_validate_study_fact_contract(
2624
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
2625
  )
2626
+ if hypothesis_ledger:
2627
+ remaining_front_rows.extend(self._hypothesis_contract_violations(
2628
+ working_abstract, hypothesis_ledger, section_name="Working Abstract"
2629
+ ))
2630
  if remaining_front_rows:
2631
  raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining_front_rows[:3]}")
2632
  quality_diagnostics.append({
 
2824
  research_logic_contract = str(
2825
  state.get("research_logic_contract") or agent.global_memory.get("research_logic_contract") or ""
2826
  )
2827
+ if hypothesis_ledger and research_logic_contract:
2828
+ research_logic_contract = self._normalize_hypothesis_ordinal_aliases(
2829
+ research_logic_contract, hypothesis_ledger
2830
+ )
2831
+ resumed_logic_rows = self._hypothesis_contract_violations(
2832
+ research_logic_contract, hypothesis_ledger, section_name="Research Logic Contract"
2833
+ )
2834
+ if resumed_logic_rows:
2835
+ raise RuntimeError(
2836
+ f"research_logic_hypothesis_contract_invalid_on_resume:{resumed_logic_rows[:3]}"
2837
+ )
2838
+ agent.global_memory["research_logic_contract"] = research_logic_contract
2839
  citation_plan = state.get("citation_plan") if isinstance(state.get("citation_plan"), dict) else {}
2840
  citation_minimums = dict(state.get("citation_minimums") or {})
2841
  citation_reserve_targets = dict(state.get("citation_reserve_targets") or {})
 
2877
  working_abstract = str(state.get("working_abstract") or "").strip()
2878
  working_keywords = list(state.get("working_keywords") or [])
2879
  if working_abstract and not state.get("english_finalized"):
2880
+ if hypothesis_ledger:
2881
+ working_abstract = self._normalize_hypothesis_ordinal_aliases(
2882
+ working_abstract, hypothesis_ledger
2883
+ )
2884
  _, resumed_front_rows = self._repair_validate_study_fact_contract(
2885
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
2886
  )
2887
+ if hypothesis_ledger:
2888
+ resumed_front_rows.extend(self._hypothesis_contract_violations(
2889
+ working_abstract, hypothesis_ledger, section_name="Working Abstract"
2890
+ ))
2891
  if resumed_front_rows:
2892
  feedback = self._section_retry_feedback(resumed_front_rows)
2893
  working_front = agent.generate_working_front_matter(
2894
  topic, keywords, research_logic_contract, synthesis_result,
2895
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
2896
+ hypothesis_contract=hypothesis_contract,
2897
  )
2898
  candidate_abstract = str(working_front.get("abstract") or "").strip()
2899
+ if hypothesis_ledger:
2900
+ candidate_abstract = self._normalize_hypothesis_ordinal_aliases(
2901
+ candidate_abstract, hypothesis_ledger
2902
+ )
2903
  candidate_keywords = list(working_front.get("keywords") or [])
2904
  _, remaining = self._repair_validate_study_fact_contract(
2905
  mode, agent, quant_engine, candidate_abstract, data_metadata, section_name="Working Abstract",
2906
  )
2907
+ if hypothesis_ledger:
2908
+ remaining.extend(self._hypothesis_contract_violations(
2909
+ candidate_abstract, hypothesis_ledger, section_name="Working Abstract"
2910
+ ))
2911
  if remaining:
2912
  raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
2913
  working_abstract = candidate_abstract
 
3106
  working_front = agent.generate_working_front_matter(
3107
  topic, keywords, research_logic_contract, synthesis_result,
3108
  data_evidence=planning_data_md, mode=mode,
3109
+ hypothesis_contract=hypothesis_contract,
3110
  )
3111
  working_abstract = str(working_front.get("abstract") or "").strip()
3112
+ if hypothesis_ledger:
3113
+ working_abstract = self._normalize_hypothesis_ordinal_aliases(
3114
+ working_abstract, hypothesis_ledger
3115
+ )
3116
  working_keywords = list(working_front.get("keywords") or [])
3117
  if not working_abstract or not working_keywords:
3118
  raise RuntimeError("working_front_matter_missing")
3119
  _, front_fact_rows = self._repair_validate_study_fact_contract(
3120
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
3121
  )
3122
+ if hypothesis_ledger:
3123
+ front_fact_rows.extend(self._hypothesis_contract_violations(
3124
+ working_abstract, hypothesis_ledger, section_name="Working Abstract"
3125
+ ))
3126
  if front_fact_rows:
3127
  feedback = self._section_retry_feedback(front_fact_rows)
3128
  working_front = agent.generate_working_front_matter(
3129
  topic, keywords, research_logic_contract, synthesis_result,
3130
  data_evidence=planning_data_md, mode=mode, validation_feedback=feedback,
3131
+ hypothesis_contract=hypothesis_contract,
3132
  )
3133
  working_abstract = str(working_front.get("abstract") or "").strip()
3134
+ if hypothesis_ledger:
3135
+ working_abstract = self._normalize_hypothesis_ordinal_aliases(
3136
+ working_abstract, hypothesis_ledger
3137
+ )
3138
  working_keywords = list(working_front.get("keywords") or [])
3139
  _, remaining = self._repair_validate_study_fact_contract(
3140
  mode, agent, quant_engine, working_abstract, data_metadata, section_name="Working Abstract",
3141
  )
3142
+ if hypothesis_ledger:
3143
+ remaining.extend(self._hypothesis_contract_violations(
3144
+ working_abstract, hypothesis_ledger, section_name="Working Abstract"
3145
+ ))
3146
  if remaining:
3147
  raise RuntimeError(f"working_front_matter_study_fact_invalid_after_retry:{remaining[:3]}")
3148
  quality_diagnostics.append({
 
3528
  en_raw = re.sub(r"(?m)^(#{2,3})\s+[\d\.\s一二三四五六七八九十、]+\s*(.*)", r"\1 \2", en_raw)
3529
  en_raw = re.sub(r"(?m)^#{4,}\s+(.*)", r"**\1**", en_raw)
3530
  en_raw = re.sub(r"(?m)^#\s+(.+)$", r"## \1", en_raw)
3531
+ if mode == "amj" and hypothesis_ledger:
3532
+ normalized_hypothesis_text = self._normalize_hypothesis_ordinal_aliases(
3533
+ en_raw, hypothesis_ledger
3534
+ )
3535
+ if normalized_hypothesis_text != en_raw:
3536
+ logging.info(
3537
+ "Deterministic hypothesis ordinal alias normalization | unit=%s",
3538
+ display_name,
3539
+ )
3540
+ en_raw = normalized_hypothesis_text
3541
  if mode == "asq":
3542
  # Model prose has no authority over quote presence/count.
3543
  en_raw = self._strip_model_controlled_qualitative_quotes(en_raw, data_metadata)
 
4043
  # front matter deterministically. No body rebalance may occur afterward.
4044
  self._progress(92, "final_front_matter")
4045
  final_front = agent.generate_final_front_matter(
4046
+ topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
4047
+ hypothesis_contract=hypothesis_contract,
4048
  )
4049
  final_abstract = str(final_front.get("abstract") or "").strip()
4050
+ if hypothesis_ledger:
4051
+ final_abstract = self._normalize_hypothesis_ordinal_aliases(
4052
+ final_abstract, hypothesis_ledger
4053
+ )
4054
  final_keywords = list(final_front.get("keywords") or [])
4055
  if not final_abstract or not final_keywords:
4056
  raise RuntimeError("final_front_matter_missing")
 
4058
  mode, agent, quant_engine, final_abstract, data_metadata,
4059
  section_name="Final Abstract",
4060
  )
4061
+ if hypothesis_ledger:
4062
+ final_front_fact_rows.extend(self._hypothesis_contract_violations(
4063
+ final_abstract, hypothesis_ledger, section_name="Final Abstract"
4064
+ ))
4065
  if final_front_fact_rows:
4066
  feedback = self._section_retry_feedback(final_front_fact_rows)
4067
  logging.warning(
 
4071
  )
4072
  final_front = agent.generate_final_front_matter(
4073
  topic, keywords, body_only, data_evidence=user_data_md, mode=mode,
4074
+ validation_feedback=feedback, hypothesis_contract=hypothesis_contract,
4075
  )
4076
  final_abstract = str(final_front.get("abstract") or "").strip()
4077
+ if hypothesis_ledger:
4078
+ final_abstract = self._normalize_hypothesis_ordinal_aliases(
4079
+ final_abstract, hypothesis_ledger
4080
+ )
4081
  final_keywords = list(final_front.get("keywords") or [])
4082
  if not final_abstract or not final_keywords:
4083
  raise RuntimeError("final_front_matter_missing_after_retry")
 
4085
  mode, agent, quant_engine, final_abstract, data_metadata,
4086
  section_name="Final Abstract",
4087
  )
4088
+ if hypothesis_ledger:
4089
+ remaining_final_fact_rows.extend(self._hypothesis_contract_violations(
4090
+ final_abstract, hypothesis_ledger, section_name="Final Abstract"
4091
+ ))
4092
  if remaining_final_fact_rows:
4093
  state["final_abstract_retry_exhausted"] = {
4094
  "attempts": 2,
 
4114
  state.pop("final_abstract_retry_exhausted", None)
4115
  full_draft_en = replace_abstract(full_draft_en, final_abstract)
4116
  full_draft_en = self._replace_keywords(full_draft_en, final_keywords)
4117
+ if mode == "amj" and hypothesis_ledger:
4118
+ post_front_hypothesis_rows = self._hypothesis_contract_violations(
4119
+ full_draft_en, hypothesis_ledger, section_name="Full Manuscript", require_all=True
4120
+ )
4121
+ if post_front_hypothesis_rows:
4122
+ raise RuntimeError(
4123
+ f"final_hypothesis_contract_failed_after_front_matter:{post_front_hypothesis_rows[:3]}"
4124
+ )
4125
  body_only, _reference_tail = agent.validate_reference_tail(
4126
  full_draft_en, minimum_references=final_reference_count
4127
  )
core/pipeline_agent.py CHANGED
@@ -195,7 +195,7 @@ class SSciPipelineAgent:
195
  self.global_memory = {
196
  "hypotheses": "", "variables": "", "model_summary": "",
197
  "method_summary": "", "research_logic_contract": "",
198
- "core_golden_metadata": []
199
  }
200
 
201
  try:
@@ -456,7 +456,10 @@ class SSciPipelineAgent:
456
 
457
  def _extract_memory_from_text(self, text, section_name):
458
  lower_sec = section_name.lower()
459
- if any(kw in lower_sec for kw in ["introduction", "literature", "hypothesis", "framing"]):
 
 
 
460
  hypo_matches = re.findall(r'(H\d+[:\s].*?)(?=\n|$)', text, re.IGNORECASE)
461
  if hypo_matches:
462
  self.global_memory["hypotheses"] = "\n".join(hypo_matches[:10])
@@ -1274,6 +1277,7 @@ class SSciPipelineAgent:
1274
  refs_text,
1275
  data_evidence="",
1276
  mode="amj",
 
1277
  ):
1278
  """Generate a provisional research-logic contract without prewriting results."""
1279
  mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
@@ -1296,6 +1300,10 @@ Literature Synthesis:
1296
 
1297
  {evidence_block}
1298
  {mode_directive}
 
 
 
 
1299
  Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It must guide later sections without fixing an expected result or conclusion. Do not use citations in the contract."""
1300
  def _stream_direct_research_logic():
1301
  # R4-A: research logic is a concise planning contract. A reasoning-first
@@ -1469,7 +1477,7 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1469
  raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
1470
 
1471
  def generate_working_front_matter(
1472
- self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback=''
1473
  ):
1474
  """Create a provisional visible abstract/keyword anchor without prewriting results."""
1475
  evidence = self._truncate_context(str(data_evidence or ''), 3500)
@@ -1490,6 +1498,10 @@ USER KEYWORD SEEDS: {keywords}
1490
  <FROZEN_RESEARCH_EVIDENCE>
1491
  {evidence}
1492
  </FROZEN_RESEARCH_EVIDENCE>
 
 
 
 
1493
  <LOCAL_VALIDATION_FEEDBACK>
1494
  {str(validation_feedback or '')[:2500]}
1495
  </LOCAL_VALIDATION_FEEDBACK>"""
@@ -1503,7 +1515,7 @@ USER KEYWORD SEEDS: {keywords}
1503
  prompt, phase='Working', max_tokens=1100, validator=validate_working,
1504
  )
1505
 
1506
- def generate_final_front_matter(self, topic, keywords, manuscript, data_evidence='', mode='amj', validation_feedback=''):
1507
  """Generate final Abstract + Keywords only from the frozen completed body/evidence."""
1508
  evidence = self._truncate_context(str(data_evidence or ''), 5000)
1509
  body = self._abstract_manuscript_context(manuscript, 18000)
@@ -1516,6 +1528,10 @@ USER KEYWORD SEEDS: {keywords}
1516
  <FROZEN_RESEARCH_EVIDENCE>
1517
  {evidence}
1518
  </FROZEN_RESEARCH_EVIDENCE>
 
 
 
 
1519
  <COMPLETED_FROZEN_MANUSCRIPT>
1520
  {body}
1521
  </COMPLETED_FROZEN_MANUSCRIPT>
@@ -2585,7 +2601,7 @@ Return ONLY a valid JSON array of strings."""
2585
  return int(match.group()) if match else 10**9
2586
 
2587
  @staticmethod
2588
- def _citation_terms(value):
2589
  stopwords = {
2590
  "about", "after", "also", "among", "and", "are", "because",
2591
  "been", "before", "between", "both", "can", "could", "does",
@@ -2596,7 +2612,11 @@ Return ONLY a valid JSON array of strings."""
2596
  "with", "within", "would",
2597
  }
2598
  words = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", str(value).lower())
2599
- return {word for word in words if word not in stopwords}
 
 
 
 
2600
 
2601
  def build_claim_evidence_plan(self, tasks, topic, synthesis_result, mode='amj', target_unique=60):
2602
  """Build a bounded citation-first plan without one oversized model call.
@@ -2810,6 +2830,17 @@ INPUT:
2810
  'use','uses','used','using','user','users','approach','approaches','framework','frameworks',
2811
  })
2812
 
 
 
 
 
 
 
 
 
 
 
 
2813
  @classmethod
2814
  def _citation_recovery_overlap(cls, section_name, section_context, claim, evidence):
2815
  anchor_terms = cls._citation_terms(f"{section_name} {section_context}")
@@ -2941,30 +2972,62 @@ INPUT:
2941
  return selected
2942
 
2943
  def _deterministic_contextual_jit_query(self, topic, section_name, section_context):
2944
- """Build one bounded section-aware recovery query without a model call."""
2945
- generic = set(self._CITATION_RECOVERY_GENERIC_TERMS)
2946
- context_terms = [
2947
- term for term in sorted(self._citation_terms(section_context))
2948
- if term not in generic and len(term) >= 3
2949
- ]
2950
- topic_terms = [
2951
- term for term in sorted(self._citation_terms(topic))
2952
- if term not in generic and len(term) >= 3
2953
- ]
2954
- section_terms = [
2955
- term for term in re.findall(r"[A-Za-z][A-Za-z-]{2,}", str(section_name or '').lower())
2956
- if term not in generic
 
 
 
 
 
 
 
 
 
 
2957
  ]
 
 
 
 
 
 
 
 
 
2958
  selected = []
2959
- # Context is authoritative because JIT exists to repair one current unit,
2960
- # not to broaden the manuscript into another application domain.
2961
- for term in context_terms + section_terms + topic_terms:
2962
- normalized = self._normalize_search_query(term)
2963
- if normalized and normalized not in selected:
2964
- selected.append(normalized)
 
 
 
2965
  if len(selected) >= 5:
2966
  break
2967
- return ' '.join(selected[:5]) if len(selected) >= 3 else ''
 
 
 
 
 
 
 
 
 
 
2968
 
2969
  def fetch_contextual_jit_recovery_items(
2970
  self, *, topic, section_name, section_context, section_id='', needed=1, excluded_tags=None,
@@ -4170,6 +4233,62 @@ INPUT:
4170
  core = f"{core} {tag}{punctuation}".strip()
4171
  return f"{leading}{core}{trailing}"
4172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4173
  def semantic_audit_claim_specific_citations(
4174
  self,
4175
  text,
@@ -4391,23 +4510,30 @@ Return exactly:
4391
  )
4392
  continue
4393
  if status == "split":
4394
- # S0-R1-C2 decision-consistency postcondition. The audit
4395
- # contract permits split only when different sources are
4396
- # actually required. If the auditor itself returns exactly
4397
- # one eligible tag, that is evidence that one source supports
4398
- # the complete claim; keep the original sentence and bind that
4399
- # one tag instead of consuming the Section retry.
4400
  if len(revised_tags) == 1:
4401
  selected = next(iter(revised_tags))
4402
- replacements[record["id"]] = self._citation_tag_only_rewrite(
4403
- record["original"], selected
 
 
4404
  )
4405
- self._append_citation_audit(
4406
- section_name, "normalize_inconsistent_split_to_single_source",
4407
- record["original"], replacements[record["id"]],
4408
- reason or "Audit returned split with only one eligible supporting source.",
4409
- )
4410
- continue
 
 
 
 
 
4411
  violation = {
4412
  "type":"citation_claim_split_required", "id":record["id"],
4413
  "sentence":record["original"][:500], "reason":reason,
 
195
  self.global_memory = {
196
  "hypotheses": "", "variables": "", "model_summary": "",
197
  "method_summary": "", "research_logic_contract": "",
198
+ "core_golden_metadata": [], "hypothesis_contract_locked": False,
199
  }
200
 
201
  try:
 
456
 
457
  def _extract_memory_from_text(self, text, section_name):
458
  lower_sec = section_name.lower()
459
+ if (
460
+ not bool(self.global_memory.get("hypothesis_contract_locked"))
461
+ and any(kw in lower_sec for kw in ["introduction", "literature", "hypothesis", "framing"])
462
+ ):
463
  hypo_matches = re.findall(r'(H\d+[:\s].*?)(?=\n|$)', text, re.IGNORECASE)
464
  if hypo_matches:
465
  self.global_memory["hypotheses"] = "\n".join(hypo_matches[:10])
 
1277
  refs_text,
1278
  data_evidence="",
1279
  mode="amj",
1280
+ hypothesis_contract="",
1281
  ):
1282
  """Generate a provisional research-logic contract without prewriting results."""
1283
  mode = 'asq' if str(mode).lower() == 'asq' else 'amj'
 
1300
 
1301
  {evidence_block}
1302
  {mode_directive}
1303
+ <IMMUTABLE_HYPOTHESIS_CONTRACT>
1304
+ {str(hypothesis_contract or '')}
1305
+ </IMMUTABLE_HYPOTHESIS_CONTRACT>
1306
+ If the immutable hypothesis contract is non-empty, it is the sole authority for H-identifiers and construct pairings. Do not redefine, reorder, or infer an alternative H1-Hn architecture.
1307
  Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It must guide later sections without fixing an expected result or conclusion. Do not use citations in the contract."""
1308
  def _stream_direct_research_logic():
1309
  # R4-A: research logic is a concise planning contract. A reasoning-first
 
1477
  raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
1478
 
1479
  def generate_working_front_matter(
1480
+ self, topic, keywords, research_logic_contract, synthesis_result, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract=''
1481
  ):
1482
  """Create a provisional visible abstract/keyword anchor without prewriting results."""
1483
  evidence = self._truncate_context(str(data_evidence or ''), 3500)
 
1498
  <FROZEN_RESEARCH_EVIDENCE>
1499
  {evidence}
1500
  </FROZEN_RESEARCH_EVIDENCE>
1501
+ <IMMUTABLE_HYPOTHESIS_CONTRACT>
1502
+ {str(hypothesis_contract or '')}
1503
+ </IMMUTABLE_HYPOTHESIS_CONTRACT>
1504
+ If the immutable hypothesis contract is non-empty, preserve its H-identifiers and construct pairings exactly; do not invent or reorder hypotheses.
1505
  <LOCAL_VALIDATION_FEEDBACK>
1506
  {str(validation_feedback or '')[:2500]}
1507
  </LOCAL_VALIDATION_FEEDBACK>"""
 
1515
  prompt, phase='Working', max_tokens=1100, validator=validate_working,
1516
  )
1517
 
1518
+ def generate_final_front_matter(self, topic, keywords, manuscript, data_evidence='', mode='amj', validation_feedback='', hypothesis_contract=''):
1519
  """Generate final Abstract + Keywords only from the frozen completed body/evidence."""
1520
  evidence = self._truncate_context(str(data_evidence or ''), 5000)
1521
  body = self._abstract_manuscript_context(manuscript, 18000)
 
1528
  <FROZEN_RESEARCH_EVIDENCE>
1529
  {evidence}
1530
  </FROZEN_RESEARCH_EVIDENCE>
1531
+ <IMMUTABLE_HYPOTHESIS_CONTRACT>
1532
+ {str(hypothesis_contract or '')}
1533
+ </IMMUTABLE_HYPOTHESIS_CONTRACT>
1534
+ If the immutable hypothesis contract is non-empty, preserve its H-identifiers and construct pairings exactly; do not invent or reorder hypotheses.
1535
  <COMPLETED_FROZEN_MANUSCRIPT>
1536
  {body}
1537
  </COMPLETED_FROZEN_MANUSCRIPT>
 
2601
  return int(match.group()) if match else 10**9
2602
 
2603
  @staticmethod
2604
+ def _citation_term_sequence(value):
2605
  stopwords = {
2606
  "about", "after", "also", "among", "and", "are", "because",
2607
  "been", "before", "between", "both", "can", "could", "does",
 
2612
  "with", "within", "would",
2613
  }
2614
  words = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", str(value).lower())
2615
+ return [word for word in words if word not in stopwords]
2616
+
2617
+ @classmethod
2618
+ def _citation_terms(cls, value):
2619
+ return set(cls._citation_term_sequence(value))
2620
 
2621
  def build_claim_evidence_plan(self, tasks, topic, synthesis_result, mode='amj', target_unique=60):
2622
  """Build a bounded citation-first plan without one oversized model call.
 
2830
  'use','uses','used','using','user','users','approach','approaches','framework','frameworks',
2831
  })
2832
 
2833
+ _CITATION_RECOVERY_QUERY_NOISE_TERMS = frozenset({
2834
+ 'academic','academics','accept','accepted','acceptance','acceptable','access','accessed',
2835
+ 'account','accounts','absence','absent','abandoned','accomplish','accomplished','achieve',
2836
+ 'achieved','achieves','across','accordingly','additional','aim','aims','allow','allows',
2837
+ 'available','based','basis','consider','considered','current','different','evidence',
2838
+ 'existing','finding','findings','important','indicate','indicates','paper','prior','process',
2839
+ 'processes','provide','provides','related','relationship','relationships','relevant','report',
2840
+ 'reports','show','shows','specific','support','supports','introduction','discussion',
2841
+ 'background','theoretical','framing','methodology','setting','results','hypothesis','hypotheses',
2842
+ })
2843
+
2844
  @classmethod
2845
  def _citation_recovery_overlap(cls, section_name, section_context, claim, evidence):
2846
  anchor_terms = cls._citation_terms(f"{section_name} {section_context}")
 
2972
  return selected
2973
 
2974
  def _deterministic_contextual_jit_query(self, topic, section_name, section_context):
2975
+ """Build one bounded topic/construct-first recovery query without a model call.
2976
+
2977
+ C5.1 deliberately preserves semantic/source order. The previous code
2978
+ converted the manuscript context to a set and then alphabetically sorted
2979
+ it, which produced queries such as ``academic accept access ...``. Topic
2980
+ terms now anchor the query, while context terms are ranked by occurrence
2981
+ frequency with first appearance as the deterministic tie-breaker.
2982
+ """
2983
+ noise = set(self._CITATION_RECOVERY_GENERIC_TERMS) | set(self._CITATION_RECOVERY_QUERY_NOISE_TERMS)
2984
+
2985
+ def clean_sequence(value):
2986
+ output = []
2987
+ for term in self._citation_term_sequence(value):
2988
+ if len(term) < 3 or term in noise or term in output:
2989
+ continue
2990
+ output.append(term)
2991
+ return output
2992
+
2993
+ topic_terms = clean_sequence(topic)
2994
+ section_terms = clean_sequence(section_name)
2995
+ raw_context = [
2996
+ term for term in self._citation_term_sequence(section_context)
2997
+ if len(term) >= 3 and term not in noise
2998
  ]
2999
+ counts = Counter(raw_context)
3000
+ first_index = {}
3001
+ for index, term in enumerate(raw_context):
3002
+ first_index.setdefault(term, index)
3003
+ context_terms = sorted(
3004
+ counts,
3005
+ key=lambda term: (-counts[term], first_index[term]),
3006
+ )
3007
+
3008
  selected = []
3009
+ # A recovery query should remain anchored in the manuscript topic rather
3010
+ # than drifting toward alphabetically early prose vocabulary.
3011
+ topic_target = min(3, len(topic_terms))
3012
+ for term in topic_terms[:topic_target]:
3013
+ if term not in selected:
3014
+ selected.append(term)
3015
+ for term in section_terms + context_terms + topic_terms[topic_target:]:
3016
+ if term not in selected:
3017
+ selected.append(term)
3018
  if len(selected) >= 5:
3019
  break
3020
+
3021
+ # When two or more topic/construct-specific terms are available, at
3022
+ # least two must survive into the bounded query. Otherwise skip JIT
3023
+ # instead of spending retrieval budget on a semantically weak query.
3024
+ if len(topic_terms) >= 2 and len(set(selected) & set(topic_terms)) < 2:
3025
+ return ''
3026
+ if len(selected) < 3:
3027
+ return ''
3028
+ if any(term in noise for term in selected):
3029
+ return ''
3030
+ return ' '.join(selected[:5])
3031
 
3032
  def fetch_contextual_jit_recovery_items(
3033
  self, *, topic, section_name, section_context, section_id='', needed=1, excluded_tags=None,
 
4233
  core = f"{core} {tag}{punctuation}".strip()
4234
  return f"{leading}{core}{trailing}"
4235
 
4236
+ def _adjudicate_single_source_split(self, *, sentence, selected_tag, evidence, split_reason, section_name=''):
4237
+ """Decide whether one selected source supports the complete compound claim.
4238
+
4239
+ This is a bounded decision-only semantic check. It has no prose rewrite,
4240
+ citation invention, or retrieval authority. Any malformed/failed decision
4241
+ fails closed as partial support so the existing same-Section retry owns the
4242
+ correction.
4243
+ """
4244
+ selected_evidence = ''
4245
+ selected_reference = ''
4246
+ for row in evidence or []:
4247
+ if str(row.get('tag') or '').strip() == str(selected_tag or '').strip():
4248
+ selected_evidence = str(row.get('evidence') or '').strip()
4249
+ selected_reference = str(row.get('reference') or '').strip()
4250
+ break
4251
+ payload = {
4252
+ 'section': str(section_name or ''),
4253
+ 'sentence': str(sentence or ''),
4254
+ 'selected_tag': str(selected_tag or ''),
4255
+ 'selected_reference': selected_reference,
4256
+ 'selected_evidence': selected_evidence,
4257
+ 'original_split_reason': str(split_reason or '')[:700],
4258
+ }
4259
+ system_prompt = (
4260
+ "You are a strict citation-support adjudicator. Decide only whether the supplied single source directly supports "
4261
+ "the complete original sentence, including every material clause. Do not rewrite prose, add citations, infer from title alone, "
4262
+ "or use outside knowledge. Return JSON only: "
4263
+ '{"decision":"complete_support|partial_or_unsupported","reason":"brief evidence-grounded reason"}.'
4264
+ )
4265
+ try:
4266
+ response = self.call_deepseek(
4267
+ system_prompt,
4268
+ json.dumps(payload, ensure_ascii=False),
4269
+ max_tokens=700,
4270
+ temp=0.0,
4271
+ model_role='reasoning',
4272
+ thinking=False,
4273
+ allow_fallback=False,
4274
+ )
4275
+ parsed = self._extract_json_from_text(response)
4276
+ if isinstance(parsed, dict):
4277
+ decision = str(parsed.get('decision') or '').strip().lower()
4278
+ reason = str(parsed.get('reason') or '').strip()
4279
+ if decision == 'complete_support':
4280
+ return True, reason
4281
+ if decision == 'partial_or_unsupported':
4282
+ return False, reason
4283
+ except PipelineCancelledError:
4284
+ raise
4285
+ except Exception as exc:
4286
+ logging.warning(
4287
+ 'Citation split adjudication failed closed: section=%s error=%s',
4288
+ section_name, type(exc).__name__,
4289
+ )
4290
+ return False, 'Single-source split adjudication did not confirm complete support.'
4291
+
4292
  def semantic_audit_claim_specific_citations(
4293
  self,
4294
  text,
 
4510
  )
4511
  continue
4512
  if status == "split":
4513
+ # C5.1: ``split + one tag`` is an inconsistent audit decision,
4514
+ # not proof of complete support. Re-adjudicate the same
4515
+ # sentence/source pair once, without rewrite/retrieval authority.
4516
+ # This preserves C2's legitimate full-support recovery while
4517
+ # preventing a partially unsupported clause from inheriting an
4518
+ # overbroad sentence-final citation.
4519
  if len(revised_tags) == 1:
4520
  selected = next(iter(revised_tags))
4521
+ complete_support, adjudication_reason = self._adjudicate_single_source_split(
4522
+ sentence=record["original"], selected_tag=selected,
4523
+ evidence=record.get("evidence") or [], split_reason=reason,
4524
+ section_name=section_name,
4525
  )
4526
+ if complete_support:
4527
+ replacements[record["id"]] = self._citation_tag_only_rewrite(
4528
+ record["original"], selected
4529
+ )
4530
+ self._append_citation_audit(
4531
+ section_name, "normalize_inconsistent_split_to_single_source",
4532
+ record["original"], replacements[record["id"]],
4533
+ adjudication_reason or reason or "Decision-only adjudication confirmed complete support.",
4534
+ )
4535
+ continue
4536
+ reason = adjudication_reason or reason
4537
  violation = {
4538
  "type":"citation_claim_split_required", "id":record["id"],
4539
  "sentence":record["original"][:500], "reason":reason,