P3-A4 resilient citation repair

#153
Files changed (2) hide show
  1. core/generation_runner.py +118 -8
  2. core/pipeline_agent.py +362 -6
core/generation_runner.py CHANGED
@@ -1902,6 +1902,21 @@ class GenerationRunner:
1902
  rows.append(' | '.join(parts))
1903
  return '\n'.join(f'- {row}' for row in rows)
1904
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1905
  @staticmethod
1906
  def _section_violation_severity(row, *, section_name: str = "", mode: str = "", exhausted: bool = False) -> str:
1907
  """Map one validation issue to REPAIRABLE/FATAL/ADVISORY.
@@ -1921,6 +1936,16 @@ class GenerationRunner:
1921
  return "ADVISORY"
1922
  return "REPAIRABLE"
1923
 
 
 
 
 
 
 
 
 
 
 
1924
  always_fatal = {
1925
  "causal_language_violation",
1926
  "sample_size_mismatch", "sample_count_mismatch", "scale_points_mismatch",
@@ -1929,7 +1954,12 @@ class GenerationRunner:
1929
  "unsupported_study_procedure", "enumeration_contract_invalid",
1930
  "hypothesis_contract_missing_id", "hypothesis_contract_ordinal_alias",
1931
  "hypothesis_contract_pairing_mismatch", "hypothesis_contract_unknown_id",
1932
- "citation_outside_frozen_section_whitelist", "authorial_ai_meta_trace",
 
 
 
 
 
1933
  "study_design_scope_mismatch", "unsupported_focal_construct", "unsupported_relation_claim",
1934
  "unsupported_moderation_claim", "unsupported_mediation_claim", "unsupported_temporal_scope",
1935
  "unsupported_design_tradition", "unsupported_unit_of_analysis", "unsupported_data_source_claim",
@@ -1972,7 +2002,21 @@ class GenerationRunner:
1972
  item = dict(row or {}) if isinstance(row, dict) else {"type": "unknown_validation_issue"}
1973
  kind = str(item.get("type") or "unknown_validation_issue")
1974
  groups = {
1975
- "citation_scope": {"citation_outside_frozen_section_whitelist"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1976
  "citation_ownership": {"citation_evidence_ownership_mismatch"},
1977
  "citation_support": {"citation_unsupported_claim", "citation_support_unverified"},
1978
  "citation_compound": {"citation_claim_split_required"},
@@ -2110,6 +2154,20 @@ class GenerationRunner:
2110
  blocking.append(row)
2111
  return blocking, advisory
2112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2113
  @staticmethod
2114
  def _record_section_advisories(quality_notes: list, rows, *, section_name: str = "") -> None:
2115
  """Append reader-facing, nonfatal notes without exposing control vocabulary."""
@@ -2177,6 +2235,7 @@ class GenerationRunner:
2177
  self, *, agent, quant_engine, text: str, violations, mode: str, section_id: str,
2178
  section_name: str, data_metadata: dict, study_evidence: str, evidence_contract: str,
2179
  hypothesis_ledger: dict, task: dict, quote_invocation_plan: dict, quality_notes: list,
 
2180
  ):
2181
  """A2 last-mile semantic repair after lineage retry exhaustion.
2182
 
@@ -2215,10 +2274,22 @@ class GenerationRunner:
2215
  repaired, _enum_rows = agent.repair_and_validate_enumeration_contract(
2216
  repaired, section_name=section_name
2217
  )
 
 
 
 
 
 
 
 
 
 
 
 
2218
  rows = self._section_validation_bundle(
2219
  mode, agent, quant_engine, repaired, data_metadata, section_id=section_id,
2220
  section_name=section_name, evidence_contract=evidence_contract, include_critic=False,
2221
- hypothesis_ledger=hypothesis_ledger,
2222
  )
2223
  blocking, advisory = self._partition_section_violations(
2224
  rows, section_name=section_name, mode=mode, exhausted=True
@@ -5600,11 +5671,8 @@ class GenerationRunner:
5600
  )
5601
  sec_title = sec["section"]
5602
  subsection = sec.get("subsection")
5603
- is_quarantined = any(
5604
- word in sec_title.lower()
5605
- for word in ["abstract", "keyword", "conclusion", "references", "appendix"]
5606
- )
5607
  section_id = str(sec.get("section_id") or "")
 
5608
  section_logical_node = self._section_logical_node(
5609
  mode, section_id, sec.get("subsection_id")
5610
  )
@@ -5630,7 +5698,7 @@ class GenerationRunner:
5630
  elif section_id == "conclusion":
5631
  mode_directive = str(APP_CONFIG.get("conclusion_anchor_directive") or "")
5632
 
5633
- if is_quarantined or section_id in {"results", "findings"}:
5634
  citation_rule = (
5635
  "NO EXTERNAL LITERATURE CITATIONS ALLOWED IN THIS UNIT. "
5636
  "Report only documented empirical/qualitative evidence and the interpretation permitted by the section role."
@@ -6097,6 +6165,29 @@ The previous attempt for this same manuscript unit was rejected by validation. R
6097
  if adaptive_advisory:
6098
  self._record_section_advisories(quality_notes, adaptive_advisory, section_name=display_name)
6099
  if adaptive_blocking:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6100
  self._record_adaptive_completion_residual(
6101
  quality_notes, adaptive_blocking, section_name=display_name, stage="phase1_semantic_repair"
6102
  )
@@ -6276,7 +6367,26 @@ The previous attempt for this same manuscript unit was rejected by validation. R
6276
  study_evidence=writer_scoped_data_md, evidence_contract=evidence_packet,
6277
  hypothesis_ledger=hypothesis_ledger, task=sec,
6278
  quote_invocation_plan=quote_invocation_plan, quality_notes=quality_notes,
 
 
6279
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6280
  logging.warning(
6281
  "Adaptive completion accepted section | unit=%s residual=%s",
6282
  display_name, len(residual_rows),
 
1902
  rows.append(' | '.join(parts))
1903
  return '\n'.join(f'- {row}' for row in rows)
1904
 
1905
+ @staticmethod
1906
+ def _section_forbids_external_citations(section_title: str = "", section_id: str = "") -> bool:
1907
+ """One authority for manuscript units that must remain citation-free.
1908
+
1909
+ P3-A4 closes a historic split where Results/Findings were declared
1910
+ citation-free in the Writer prompt but did not enter the same quarantine
1911
+ validation path as Conclusion/Appendix-style units.
1912
+ """
1913
+ title = str(section_title or '').casefold()
1914
+ sid = str(section_id or '').strip().casefold()
1915
+ return (
1916
+ any(word in title for word in ("abstract", "keyword", "conclusion", "references", "appendix"))
1917
+ or sid in {"results", "findings"}
1918
+ )
1919
+
1920
  @staticmethod
1921
  def _section_violation_severity(row, *, section_name: str = "", mode: str = "", exhausted: bool = False) -> str:
1922
  """Map one validation issue to REPAIRABLE/FATAL/ADVISORY.
 
1936
  return "ADVISORY"
1937
  return "REPAIRABLE"
1938
 
1939
+ # Delivery-first resilience: uncertain noncanonical citation-like forms
1940
+ # get a DeepSeek repair opportunity, but they are not allowed to terminate
1941
+ # an otherwise complete manuscript after bounded repair. Definite
1942
+ # authority/scope violations remain fail-closed below.
1943
+ if kind in {
1944
+ "citation_noncanonical_unverified_marker",
1945
+ "citation_suspected_in_quarantined_unit",
1946
+ }:
1947
+ return "ADVISORY"
1948
+
1949
  always_fatal = {
1950
  "causal_language_violation",
1951
  "sample_size_mismatch", "sample_count_mismatch", "scale_points_mismatch",
 
1954
  "unsupported_study_procedure", "enumeration_contract_invalid",
1955
  "hypothesis_contract_missing_id", "hypothesis_contract_ordinal_alias",
1956
  "hypothesis_contract_pairing_mismatch", "hypothesis_contract_unknown_id",
1957
+ "citation_outside_frozen_section_whitelist",
1958
+ "citation_resolved_marker_outside_frozen_section_whitelist",
1959
+ "citation_unknown_tag", "citation_resolved_marker_without_authority",
1960
+ "citation_resolved_marker_ambiguous", "citation_noncanonical_resolved_marker",
1961
+ "citation_forbidden_in_quarantined_unit",
1962
+ "authorial_ai_meta_trace",
1963
  "study_design_scope_mismatch", "unsupported_focal_construct", "unsupported_relation_claim",
1964
  "unsupported_moderation_claim", "unsupported_mediation_claim", "unsupported_temporal_scope",
1965
  "unsupported_design_tradition", "unsupported_unit_of_analysis", "unsupported_data_source_claim",
 
2002
  item = dict(row or {}) if isinstance(row, dict) else {"type": "unknown_validation_issue"}
2003
  kind = str(item.get("type") or "unknown_validation_issue")
2004
  groups = {
2005
+ "citation_scope": {
2006
+ "citation_outside_frozen_section_whitelist",
2007
+ "citation_resolved_marker_outside_frozen_section_whitelist",
2008
+ },
2009
+ "citation_authority": {
2010
+ "citation_unknown_tag",
2011
+ "citation_resolved_marker_without_authority",
2012
+ "citation_resolved_marker_ambiguous",
2013
+ "citation_noncanonical_resolved_marker",
2014
+ "citation_forbidden_in_quarantined_unit",
2015
+ },
2016
+ "citation_style": {
2017
+ "citation_noncanonical_unverified_marker",
2018
+ "citation_suspected_in_quarantined_unit",
2019
+ },
2020
  "citation_ownership": {"citation_evidence_ownership_mismatch"},
2021
  "citation_support": {"citation_unsupported_claim", "citation_support_unverified"},
2022
  "citation_compound": {"citation_claim_split_required"},
 
2154
  blocking.append(row)
2155
  return blocking, advisory
2156
 
2157
+ @classmethod
2158
+ def _persistent_citation_authority_rows(cls, rows) -> list[dict]:
2159
+ """Return citation identity/scope defects that may never cross a Section seal.
2160
+
2161
+ P3-A4 intentionally keeps this narrower than general validation failure.
2162
+ Citation support/style advisories retain their existing adaptive-completion
2163
+ behavior; only source identity and unit-scope authority stay fail-closed.
2164
+ """
2165
+ return [
2166
+ row for row in list(rows or [])
2167
+ if isinstance(row, dict)
2168
+ and cls._section_violation_lineage(row) in {"citation_scope", "citation_authority"}
2169
+ ]
2170
+
2171
  @staticmethod
2172
  def _record_section_advisories(quality_notes: list, rows, *, section_name: str = "") -> None:
2173
  """Append reader-facing, nonfatal notes without exposing control vocabulary."""
 
2235
  self, *, agent, quant_engine, text: str, violations, mode: str, section_id: str,
2236
  section_name: str, data_metadata: dict, study_evidence: str, evidence_contract: str,
2237
  hypothesis_ledger: dict, task: dict, quote_invocation_plan: dict, quality_notes: list,
2238
+ allowed_citation_tags=None, quarantine=False,
2239
  ):
2240
  """A2 last-mile semantic repair after lineage retry exhaustion.
2241
 
 
2274
  repaired, _enum_rows = agent.repair_and_validate_enumeration_contract(
2275
  repaired, section_name=section_name
2276
  )
2277
+ # P3-A4: every model-owned last-mile rewrite must re-enter the same
2278
+ # citation authority + claim-specific semantic Gate before it can be
2279
+ # considered sealable. Otherwise an unrelated adaptive repair could
2280
+ # reintroduce a pre-resolved/unknown/wrong-scope citation after the
2281
+ # earlier Section citation audit had already passed.
2282
+ repaired = agent.audit_and_resolve_citations(
2283
+ repaired, section_name=section_name, section_id=section_id,
2284
+ quarantine=quarantine, allowed_tags=allowed_citation_tags,
2285
+ )
2286
+ adaptive_citation_rows = copy.deepcopy(
2287
+ list(getattr(agent, "_last_citation_semantic_violations", []) or [])
2288
+ )
2289
  rows = self._section_validation_bundle(
2290
  mode, agent, quant_engine, repaired, data_metadata, section_id=section_id,
2291
  section_name=section_name, evidence_contract=evidence_contract, include_critic=False,
2292
+ citation_violations=adaptive_citation_rows, hypothesis_ledger=hypothesis_ledger,
2293
  )
2294
  blocking, advisory = self._partition_section_violations(
2295
  rows, section_name=section_name, mode=mode, exhausted=True
 
5671
  )
5672
  sec_title = sec["section"]
5673
  subsection = sec.get("subsection")
 
 
 
 
5674
  section_id = str(sec.get("section_id") or "")
5675
+ is_quarantined = self._section_forbids_external_citations(sec_title, section_id)
5676
  section_logical_node = self._section_logical_node(
5677
  mode, section_id, sec.get("subsection_id")
5678
  )
 
5698
  elif section_id == "conclusion":
5699
  mode_directive = str(APP_CONFIG.get("conclusion_anchor_directive") or "")
5700
 
5701
+ if is_quarantined:
5702
  citation_rule = (
5703
  "NO EXTERNAL LITERATURE CITATIONS ALLOWED IN THIS UNIT. "
5704
  "Report only documented empirical/qualitative evidence and the interpretation permitted by the section role."
 
6165
  if adaptive_advisory:
6166
  self._record_section_advisories(quality_notes, adaptive_advisory, section_name=display_name)
6167
  if adaptive_blocking:
6168
+ # P3-A4: citation identity/scope authority is a hard
6169
+ # pre-seal invariant. Adaptive completion may retain
6170
+ # reader-facing advisories for unrelated quality
6171
+ # defects, but it may never seal a Section that still
6172
+ # contains an unauthorized/ambiguous citation identity
6173
+ # or a citation outside the frozen unit whitelist.
6174
+ persistent_citation_authority = self._persistent_citation_authority_rows(
6175
+ adaptive_blocking
6176
+ )
6177
+ if persistent_citation_authority:
6178
+ self._checkpoint_section_retry_exhausted(
6179
+ state, section_id=section_id, section_name=display_name,
6180
+ violations=persistent_citation_authority, pct=pct, attempts=section_attempt,
6181
+ )
6182
+ raise RuntimeError(
6183
+ "citation_authority_repair_exhausted:"
6184
+ + str(display_name)
6185
+ + ":"
6186
+ + ",".join(sorted({
6187
+ str((row or {}).get("type") or "citation_authority")
6188
+ for row in persistent_citation_authority if isinstance(row, dict)
6189
+ }))
6190
+ )
6191
  self._record_adaptive_completion_residual(
6192
  quality_notes, adaptive_blocking, section_name=display_name, stage="phase1_semantic_repair"
6193
  )
 
6367
  study_evidence=writer_scoped_data_md, evidence_contract=evidence_packet,
6368
  hypothesis_ledger=hypothesis_ledger, task=sec,
6369
  quote_invocation_plan=quote_invocation_plan, quality_notes=quality_notes,
6370
+ allowed_citation_tags=allowed_section_citation_tags,
6371
+ quarantine=is_quarantined,
6372
  )
6373
+ persistent_citation_authority = self._persistent_citation_authority_rows(
6374
+ residual_rows
6375
+ )
6376
+ if persistent_citation_authority:
6377
+ self._checkpoint_section_retry_exhausted(
6378
+ state, section_id=section_id, section_name=display_name,
6379
+ violations=persistent_citation_authority, pct=pct, attempts=section_attempt,
6380
+ )
6381
+ raise RuntimeError(
6382
+ "citation_authority_repair_exhausted:"
6383
+ + str(display_name)
6384
+ + ":"
6385
+ + ",".join(sorted({
6386
+ str((row or {}).get("type") or "citation_authority")
6387
+ for row in persistent_citation_authority if isinstance(row, dict)
6388
+ }))
6389
+ )
6390
  logging.warning(
6391
  "Adaptive completion accepted section | unit=%s residual=%s",
6392
  display_name, len(residual_rows),
core/pipeline_agent.py CHANGED
@@ -6349,6 +6349,314 @@ Return exactly:
6349
  )
6350
  return revised
6351
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6352
  def audit_and_resolve_citations(
6353
  self, text, section_name='', section_id='', quarantine=False, allowed_tags=None
6354
  ):
@@ -6358,12 +6666,41 @@ Return exactly:
6358
  )
6359
  value = self.strip_ineligible_current_study_citations(str(text or ''), section_name)
6360
  if quarantine:
6361
- return self.resolve_citations(value, quarantine=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6362
  unknown = sorted({f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, re.I)} - set(self.citation_map))
6363
  if unknown:
6364
  for tag in unknown:
 
 
 
 
 
 
 
 
 
6365
  value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
6366
- self._append_citation_audit(section_name, 'remove_unknown_tags', text, value, ','.join(unknown))
 
6367
  allowed = None if allowed_tags is None else {
6368
  str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
6369
  }
@@ -6383,6 +6720,16 @@ Return exactly:
6383
  self._append_citation_audit(
6384
  section_name, 'reject_outside_frozen_section_whitelist', text, value, tag
6385
  )
 
 
 
 
 
 
 
 
 
 
6386
  has_citations = bool(re.search(r'CITE-\d+', value, re.I))
6387
  violations = self.find_multi_citation_violations(value)
6388
  if has_citations and self.citation_semantic_audit.get('enabled', True):
@@ -6413,12 +6760,21 @@ Return exactly:
6413
  value = self._local_citation_recovery(value, section_name, retain_lexical=False)
6414
  elif violations:
6415
  value = self._local_citation_recovery(value, section_name)
6416
- if ownership_rows or outside_rows:
 
 
6417
  self._last_citation_semantic_violations = (
6418
- list(ownership_rows)
6419
- + list(self._last_citation_semantic_violations or [])
6420
- + list(outside_rows)
6421
  )
 
 
 
 
 
 
 
 
 
6422
  if self.find_multi_citation_violations(value):
6423
  value = self._local_citation_recovery(value, section_name)
6424
  if self.find_multi_citation_violations(value):
 
6349
  )
6350
  return revised
6351
 
6352
+ @staticmethod
6353
+ def _high_confidence_noncanonical_citation_spans(text):
6354
+ """Return only *suspected* noncanonical citation forms worth one soft repair pass.
6355
+
6356
+ P3-A4-R4 delivery-first rule:
6357
+ - exact aliases of authoritative citation_map entries are handled separately
6358
+ and deterministically by ``_normalize_pre_resolved_citation_authority``;
6359
+ - unmatched narrative ``Name (year)`` forms are surfaced only when nearby
6360
+ attribution language makes citation intent high-confidence;
6361
+ - bracketed ``[Name, year]`` forms remain suspicious, but are treated as
6362
+ repairable/advisory rather than a terminal authority failure when they do
6363
+ not map to citation_map.
6364
+
6365
+ This deliberately avoids treating ordinary product/method/version prose such
6366
+ as ``Stata (2024)``, ``Mplus (2024)``, ``SmartPLS (2024)``,
6367
+ ``DeepSeek (2024)``, or ``SEM (2024)`` as citations merely because the
6368
+ surface form resembles an author-year citation.
6369
+ """
6370
+ value = str(text or '')
6371
+ surname = r"[A-ZÀ-ÖØ-Þ][A-Za-zÀ-ÖØ-öø-ÿ'’.-]{1,39}"
6372
+ conjunction = r"(?:&|and|AND)"
6373
+ et_al = r"(?:et|Et|ET)\s+(?:al|Al|AL)\."
6374
+ author_group = rf"(?:{surname}(?:\s+{conjunction}\s+{surname})?|{surname}\s+{et_al})"
6375
+ year = r"(?:19|20)\d{2}[a-z]?"
6376
+ bracketed = re.compile(rf"\[(?P<cite>{author_group}\s*,\s*{year})\]")
6377
+ narrative = re.compile(rf"(?<![\w\[])(?P<cite>{author_group})\s*\(\s*(?P<year>{year})\s*\)")
6378
+ attribution_after = re.compile(
6379
+ r"^\s*(?:,\s*)?(?:argues?|reports?|finds?|found|suggests?|shows?|showed|"
6380
+ r"demonstrates?|demonstrated|proposes?|notes?|observes?|states?|concludes?|"
6381
+ r"documents?|identifies?|reported|suggested|proposed|noted|observed|stated|"
6382
+ r"concluded|documented|identified)\b",
6383
+ re.I,
6384
+ )
6385
+ attribution_before = re.compile(
6386
+ r"(?:according\s+to|as\s+(?:reported|noted|shown|argued|observed|stated)\s+by)\s*$",
6387
+ re.I,
6388
+ )
6389
+
6390
+ spans = []
6391
+ for match in bracketed.finditer(value):
6392
+ spans.append((match.start(), match.end(), match.group(0).strip()))
6393
+ for match in narrative.finditer(value):
6394
+ before = value[max(0, match.start() - 48):match.start()]
6395
+ after = value[match.end():min(len(value), match.end() + 64)]
6396
+ if attribution_after.search(after) or attribution_before.search(before):
6397
+ spans.append((match.start(), match.end(), match.group(0).strip()))
6398
+
6399
+ unique = []
6400
+ seen = set()
6401
+ for row in sorted(spans, key=lambda item: (item[0], item[1], item[2])):
6402
+ key = (row[0], row[1], row[2])
6403
+ if key not in seen:
6404
+ seen.add(key)
6405
+ unique.append(row)
6406
+ return unique
6407
+
6408
+ def _authoritative_noncanonical_citation_aliases(self):
6409
+ """Map exact narrative/bracketed aliases to authoritative CITE tags.
6410
+
6411
+ The alias set is derived only from compiled citation_map markers carrying
6412
+ authoritative reference metadata. No free-form author/year guessing is
6413
+ performed.
6414
+ """
6415
+ self.compile_citation_identities()
6416
+ aliases = {}
6417
+ for tag, metadata in (self.citation_map or {}).items():
6418
+ meta = metadata or {}
6419
+ marker = str(meta.get('in_text') or '').strip()
6420
+ if not marker or not str(meta.get('ref') or '').strip():
6421
+ continue
6422
+ match = re.fullmatch(
6423
+ r"\((?P<authors>[^()]+?)\s*,\s*(?P<year>(?:19|20)\d{2}[a-z]?)\)",
6424
+ marker,
6425
+ flags=re.I,
6426
+ )
6427
+ if not match:
6428
+ continue
6429
+ authors = match.group('authors').strip()
6430
+ year = match.group('year').strip()
6431
+ for alias in (f"{authors} ({year})", f"[{authors}, {year}]"):
6432
+ aliases.setdefault(alias, []).append(str(tag))
6433
+ return aliases
6434
+ def _normalize_pre_resolved_citation_authority(self, text, *, allowed_tags=None, section_name=''):
6435
+ """Normalize only exact, authoritative pre-resolved markers back to CITE tags.
6436
+
6437
+ The Section Writer is supposed to emit frozen ``[CITE-XX]`` capabilities.
6438
+ A model-emitted reader-facing author-year marker therefore has no provenance
6439
+ until it is matched exactly against the *compiled* citation authority.
6440
+
6441
+ Safe normalization requires all of the following:
6442
+ - exact marker equality after ``compile_citation_identities()``;
6443
+ - authoritative reference metadata;
6444
+ - one distinct source identity for the visible marker;
6445
+ - at least one matching tag inside this manuscript unit's whitelist.
6446
+
6447
+ Anything else becomes a typed violation. No fuzzy author/year matching,
6448
+ metadata synthesis, or silent citation deletion is permitted here.
6449
+ """
6450
+ value = str(text or '')
6451
+ allowed = None if allowed_tags is None else {
6452
+ str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
6453
+ }
6454
+ self.compile_citation_identities()
6455
+
6456
+ authoritative = {}
6457
+ visible_any = {}
6458
+ for tag, metadata in (self.citation_map or {}).items():
6459
+ meta = metadata or {}
6460
+ marker = str(meta.get('in_text') or '').strip()
6461
+ if not marker:
6462
+ continue
6463
+ visible_any.setdefault(marker, []).append(str(tag))
6464
+ if str(meta.get('ref') or '').strip():
6465
+ authoritative.setdefault(marker, []).append(str(tag))
6466
+
6467
+ violations = []
6468
+ replacements = []
6469
+ for marker in list(dict.fromkeys(self._resolved_parenthetical_citations(value))):
6470
+ tags = list(authoritative.get(marker) or [])
6471
+ if not tags:
6472
+ visible_tags = sorted(visible_any.get(marker) or [], key=self._citation_tag_number)
6473
+ violations.append({
6474
+ 'type': 'citation_resolved_marker_without_authority',
6475
+ 'citation': marker,
6476
+ 'tag': visible_tags[0] if len(visible_tags) == 1 else '',
6477
+ 'sentence': marker,
6478
+ 'reason': 'A reader-facing author-year citation was emitted without authoritative reference metadata.',
6479
+ 'repair_instruction': (
6480
+ 'Do not emit author-year citations directly. Use only an authorized CITE tag from this manuscript unit '
6481
+ 'when its evidence directly supports the claim, or narrow/rewrite the claim so no unsupported citation is needed.'
6482
+ ),
6483
+ })
6484
+ continue
6485
+
6486
+ distinct_sources = {
6487
+ self._citation_source_identity(self.citation_map.get(tag) or {})
6488
+ for tag in tags
6489
+ }
6490
+ if len(distinct_sources) != 1:
6491
+ violations.append({
6492
+ 'type': 'citation_resolved_marker_ambiguous',
6493
+ 'citation': marker,
6494
+ 'sentence': marker,
6495
+ 'reason': 'The reader-facing author-year marker maps to multiple distinct authoritative sources.',
6496
+ 'repair_instruction': (
6497
+ 'Do not guess which source was intended. Rewrite this claim using exactly one authorized CITE tag '
6498
+ 'from the frozen evidence plan, or remove/narrow the unsupported citation-dependent claim.'
6499
+ ),
6500
+ })
6501
+ continue
6502
+
6503
+ eligible = tags if allowed is None else [tag for tag in tags if tag in allowed]
6504
+ if not eligible:
6505
+ violations.append({
6506
+ 'type': 'citation_resolved_marker_outside_frozen_section_whitelist',
6507
+ 'citation': marker,
6508
+ 'tag': sorted(tags, key=self._citation_tag_number)[0] if tags else '',
6509
+ 'sentence': marker,
6510
+ 'reason': 'The citation is authoritative globally but is not authorized for this manuscript unit.',
6511
+ 'repair_instruction': (
6512
+ 'Use only citation tags frozen for this manuscript unit. If no eligible source directly supports the claim, '
6513
+ 'narrow or rewrite the claim without external citation support.'
6514
+ ),
6515
+ })
6516
+ continue
6517
+
6518
+ selected = sorted(eligible, key=self._citation_tag_number)[0]
6519
+ replacements.append((marker, selected))
6520
+ self._append_citation_audit(
6521
+ section_name, 'normalize_exact_pre_resolved_marker_to_authoritative_tag',
6522
+ marker, selected, 'Exact compiled marker + authoritative reference + current-unit whitelist.'
6523
+ )
6524
+
6525
+ # Exact narrative/bracketed aliases of authoritative citation_map entries
6526
+ # are a formatting defect, not a reason to kill generation. Normalize
6527
+ # them deterministically back to the owning CITE tag and let the existing
6528
+ # claim-specific semantic audit validate the claim/source relationship.
6529
+ alias_authority = self._authoritative_noncanonical_citation_aliases()
6530
+ authoritative_aliases_seen = set()
6531
+ for alias, alias_tags in alias_authority.items():
6532
+ if alias not in value:
6533
+ continue
6534
+ tags = list(alias_tags or [])
6535
+ authoritative_aliases_seen.add(alias)
6536
+ distinct_sources = {
6537
+ self._citation_source_identity(self.citation_map.get(tag) or {})
6538
+ for tag in tags
6539
+ }
6540
+ if len(distinct_sources) != 1:
6541
+ violations.append({
6542
+ 'type': 'citation_resolved_marker_ambiguous',
6543
+ 'citation': alias,
6544
+ 'sentence': alias,
6545
+ 'reason': 'The noncanonical author-year marker maps to multiple distinct authoritative sources.',
6546
+ 'repair_instruction': (
6547
+ 'Do not guess which source was intended. Rewrite this claim using exactly one authorized CITE tag '
6548
+ 'from the frozen evidence plan, or remove/narrow the unsupported citation-dependent claim.'
6549
+ ),
6550
+ })
6551
+ continue
6552
+
6553
+ eligible = tags if allowed is None else [tag for tag in tags if tag in allowed]
6554
+ if not eligible:
6555
+ violations.append({
6556
+ 'type': 'citation_resolved_marker_outside_frozen_section_whitelist',
6557
+ 'citation': alias,
6558
+ 'tag': sorted(tags, key=self._citation_tag_number)[0] if tags else '',
6559
+ 'sentence': alias,
6560
+ 'reason': 'The citation is authoritative globally but is not authorized for this manuscript unit.',
6561
+ 'repair_instruction': (
6562
+ 'Use only citation tags frozen for this manuscript unit. If no eligible source directly supports the claim, '
6563
+ 'narrow or rewrite the claim without external citation support.'
6564
+ ),
6565
+ })
6566
+ continue
6567
+
6568
+ selected = sorted(eligible, key=self._citation_tag_number)[0]
6569
+ replacements.append((alias, selected))
6570
+ self._append_citation_audit(
6571
+ section_name, 'normalize_exact_noncanonical_marker_to_authoritative_tag',
6572
+ alias, selected, 'Exact authoritative alias + current-unit whitelist.'
6573
+ )
6574
+
6575
+ # Unmatched suspicious author-year forms get one DeepSeek-owned repair
6576
+ # opportunity but are explicitly *soft* after bounded repair. This keeps
6577
+ # the system resilient when a product/method/model label happens to look
6578
+ # like a surname + year while still asking the model to clean genuinely
6579
+ # citation-like prose when attribution context is strong.
6580
+ for _start, _end, citation in self._high_confidence_noncanonical_citation_spans(value):
6581
+ if citation in authoritative_aliases_seen:
6582
+ continue
6583
+ violations.append({
6584
+ 'type': 'citation_noncanonical_unverified_marker',
6585
+ 'citation': citation,
6586
+ 'sentence': citation,
6587
+ 'reason': 'A citation-like author-year form could not be bound exactly to frozen citation authority.',
6588
+ 'repair_instruction': (
6589
+ 'If this is intended as a literature citation, replace it with one authorized CITE tag whose evidence directly '
6590
+ 'supports the claim. If it is a product, method, model, software, or ordinary year label, keep the factual prose '
6591
+ 'but rewrite it so it cannot be mistaken for an external author-year citation.'
6592
+ ),
6593
+ })
6594
+
6595
+ for marker, tag in replacements:
6596
+ value = value.replace(marker, tag)
6597
+ return value, violations
6598
+ def _quarantined_unit_citation_violations(self, text):
6599
+ """Return definite and suspected citation forms in citation-free units.
6600
+
6601
+ Delivery-first classification:
6602
+ - raw CITE tags, canonical resolved parenthetical markers, and exact
6603
+ authoritative narrative/bracketed aliases are definite literature
6604
+ citations and remain fail-closed after bounded repair;
6605
+ - unmatched citation-like narrative/bracketed forms are only suspected.
6606
+ They receive a DeepSeek repair opportunity but may degrade to an
6607
+ advisory instead of terminating an otherwise complete manuscript.
6608
+ """
6609
+ value = str(text or '')
6610
+ rows = []
6611
+ seen = set()
6612
+
6613
+ def add(kind, citation, reason, instruction):
6614
+ key = (kind, str(citation or ''))
6615
+ if key in seen:
6616
+ return
6617
+ seen.add(key)
6618
+ rows.append({
6619
+ 'type': kind,
6620
+ 'citation': str(citation or ''),
6621
+ 'sentence': str(citation or ''),
6622
+ 'reason': reason,
6623
+ 'repair_instruction': instruction,
6624
+ })
6625
+
6626
+ definite_reason = 'This manuscript unit forbids external literature citations, but a definite citation form was emitted.'
6627
+ definite_instruction = (
6628
+ 'Rewrite this unit without external literature citations. Preserve only documented study evidence, '
6629
+ 'study-owned interpretation, and the section role; do not replace the citation with another source.'
6630
+ )
6631
+
6632
+ for raw in re.findall(r'\[?CITE-\d+\]?', value, flags=re.I):
6633
+ add('citation_forbidden_in_quarantined_unit', raw.upper(), definite_reason, definite_instruction)
6634
+
6635
+ for marker in self._resolved_parenthetical_citations(value):
6636
+ add('citation_forbidden_in_quarantined_unit', marker, definite_reason, definite_instruction)
6637
+
6638
+ authoritative_aliases = self._authoritative_noncanonical_citation_aliases()
6639
+ authoritative_seen = set()
6640
+ for alias in authoritative_aliases:
6641
+ if alias in value:
6642
+ authoritative_seen.add(alias)
6643
+ add('citation_forbidden_in_quarantined_unit', alias, definite_reason, definite_instruction)
6644
+
6645
+ for _start, _end, citation in self._high_confidence_noncanonical_citation_spans(value):
6646
+ if citation in authoritative_seen:
6647
+ continue
6648
+ add(
6649
+ 'citation_suspected_in_quarantined_unit',
6650
+ citation,
6651
+ 'A citation-like author-year form appeared in a citation-free manuscript unit but was not bound to frozen authority.',
6652
+ (
6653
+ 'Use DeepSeek to rewrite the passage without external literature if this is a citation. If it is instead a '
6654
+ 'product, method, model, software, or ordinary year label, preserve the factual content and clarify the wording. '
6655
+ 'Do not terminate manuscript generation solely because this soft ambiguity remains after bounded repair.'
6656
+ ),
6657
+ )
6658
+
6659
+ return rows
6660
  def audit_and_resolve_citations(
6661
  self, text, section_name='', section_id='', quarantine=False, allowed_tags=None
6662
  ):
 
6666
  )
6667
  value = self.strip_ineligible_current_study_citations(str(text or ''), section_name)
6668
  if quarantine:
6669
+ quarantine_rows = self._quarantined_unit_citation_violations(value)
6670
+ if ownership_rows or quarantine_rows:
6671
+ # Do not silently delete a citation-dependent claim. The owning
6672
+ # Section retry/adaptive-repair path receives a typed violation
6673
+ # and must rewrite the unit without external literature.
6674
+ self._last_citation_semantic_violations = (
6675
+ list(ownership_rows) + list(quarantine_rows)
6676
+ )
6677
+ for row in quarantine_rows:
6678
+ self._append_citation_audit(
6679
+ section_name, 'reject_citation_in_quarantined_unit',
6680
+ str(row.get('citation') or ''), '', str(row.get('reason') or '')
6681
+ )
6682
+ return value
6683
+ return value
6684
+
6685
+ # P3-A4: collect deterministic citation-authority violations separately.
6686
+ # semantic_audit_claim_specific_citations() owns its own last-violation
6687
+ # buffer, so these rows are merged back only after that audit completes.
6688
+ authority_rows = []
6689
  unknown = sorted({f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, re.I)} - set(self.citation_map))
6690
  if unknown:
6691
  for tag in unknown:
6692
+ authority_rows.append({
6693
+ 'type': 'citation_unknown_tag',
6694
+ 'tag': tag,
6695
+ 'sentence': tag,
6696
+ 'reason': 'The manuscript unit emitted a citation capability that does not exist in authoritative citation_map.',
6697
+ 'repair_instruction': (
6698
+ 'Use only citation tags frozen for this manuscript unit, or narrow/rewrite the claim so no unsupported citation is needed.'
6699
+ ),
6700
+ })
6701
  value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
6702
+ self._append_citation_audit(section_name, 'reject_unknown_tags_for_section_retry', text, value, ','.join(unknown))
6703
+
6704
  allowed = None if allowed_tags is None else {
6705
  str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
6706
  }
 
6720
  self._append_citation_audit(
6721
  section_name, 'reject_outside_frozen_section_whitelist', text, value, tag
6722
  )
6723
+
6724
+ # P3-A4 closes the resolved-marker bypass before section sealing. Exact,
6725
+ # unambiguous, current-unit-authorized markers are normalized back to raw
6726
+ # capabilities so the existing claim-specific semantic audit still owns
6727
+ # claim/evidence adjudication. All other forms become typed violations.
6728
+ value, resolved_authority_rows = self._normalize_pre_resolved_citation_authority(
6729
+ value, allowed_tags=allowed, section_name=section_name
6730
+ )
6731
+ authority_rows.extend(resolved_authority_rows)
6732
+
6733
  has_citations = bool(re.search(r'CITE-\d+', value, re.I))
6734
  violations = self.find_multi_citation_violations(value)
6735
  if has_citations and self.citation_semantic_audit.get('enabled', True):
 
6760
  value = self._local_citation_recovery(value, section_name, retain_lexical=False)
6761
  elif violations:
6762
  value = self._local_citation_recovery(value, section_name)
6763
+
6764
+ pre_semantic_rows = list(ownership_rows) + list(outside_rows) + list(authority_rows)
6765
+ if pre_semantic_rows:
6766
  self._last_citation_semantic_violations = (
6767
+ pre_semantic_rows + list(self._last_citation_semantic_violations or [])
 
 
6768
  )
6769
+
6770
+ # P3-A4 reliability rule: once deterministic identity/scope authority has
6771
+ # already rejected this attempt, return it to the owning bounded Section
6772
+ # retry instead of letting the legacy multi-source hard exception preempt
6773
+ # typed recovery. The rejected bytes can never be sealed because runner
6774
+ # treats these rows as a hard pre-seal invariant.
6775
+ if outside_rows or authority_rows:
6776
+ return value
6777
+
6778
  if self.find_multi_citation_violations(value):
6779
  value = self._local_citation_recovery(value, section_name)
6780
  if self.find_multi_citation_violations(value):