P3-A4 minimal citation authority repair on live main

#152
Files changed (2) hide show
  1. core/generation_runner.py +104 -8
  2. core/pipeline_agent.py +244 -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.
@@ -1929,7 +1944,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 +1992,17 @@ 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 +2140,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 +2221,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 +2260,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 +5657,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 +5684,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 +6151,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 +6353,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.
 
1944
  "unsupported_study_procedure", "enumeration_contract_invalid",
1945
  "hypothesis_contract_missing_id", "hypothesis_contract_ordinal_alias",
1946
  "hypothesis_contract_pairing_mismatch", "hypothesis_contract_unknown_id",
1947
+ "citation_outside_frozen_section_whitelist",
1948
+ "citation_resolved_marker_outside_frozen_section_whitelist",
1949
+ "citation_unknown_tag", "citation_resolved_marker_without_authority",
1950
+ "citation_resolved_marker_ambiguous", "citation_noncanonical_resolved_marker",
1951
+ "citation_forbidden_in_quarantined_unit",
1952
+ "authorial_ai_meta_trace",
1953
  "study_design_scope_mismatch", "unsupported_focal_construct", "unsupported_relation_claim",
1954
  "unsupported_moderation_claim", "unsupported_mediation_claim", "unsupported_temporal_scope",
1955
  "unsupported_design_tradition", "unsupported_unit_of_analysis", "unsupported_data_source_claim",
 
1992
  item = dict(row or {}) if isinstance(row, dict) else {"type": "unknown_validation_issue"}
1993
  kind = str(item.get("type") or "unknown_validation_issue")
1994
  groups = {
1995
+ "citation_scope": {
1996
+ "citation_outside_frozen_section_whitelist",
1997
+ "citation_resolved_marker_outside_frozen_section_whitelist",
1998
+ },
1999
+ "citation_authority": {
2000
+ "citation_unknown_tag",
2001
+ "citation_resolved_marker_without_authority",
2002
+ "citation_resolved_marker_ambiguous",
2003
+ "citation_noncanonical_resolved_marker",
2004
+ "citation_forbidden_in_quarantined_unit",
2005
+ },
2006
  "citation_ownership": {"citation_evidence_ownership_mismatch"},
2007
  "citation_support": {"citation_unsupported_claim", "citation_support_unverified"},
2008
  "citation_compound": {"citation_claim_split_required"},
 
2140
  blocking.append(row)
2141
  return blocking, advisory
2142
 
2143
+ @classmethod
2144
+ def _persistent_citation_authority_rows(cls, rows) -> list[dict]:
2145
+ """Return citation identity/scope defects that may never cross a Section seal.
2146
+
2147
+ P3-A4 intentionally keeps this narrower than general validation failure.
2148
+ Citation support/style advisories retain their existing adaptive-completion
2149
+ behavior; only source identity and unit-scope authority stay fail-closed.
2150
+ """
2151
+ return [
2152
+ row for row in list(rows or [])
2153
+ if isinstance(row, dict)
2154
+ and cls._section_violation_lineage(row) in {"citation_scope", "citation_authority"}
2155
+ ]
2156
+
2157
  @staticmethod
2158
  def _record_section_advisories(quality_notes: list, rows, *, section_name: str = "") -> None:
2159
  """Append reader-facing, nonfatal notes without exposing control vocabulary."""
 
2221
  self, *, agent, quant_engine, text: str, violations, mode: str, section_id: str,
2222
  section_name: str, data_metadata: dict, study_evidence: str, evidence_contract: str,
2223
  hypothesis_ledger: dict, task: dict, quote_invocation_plan: dict, quality_notes: list,
2224
+ allowed_citation_tags=None, quarantine=False,
2225
  ):
2226
  """A2 last-mile semantic repair after lineage retry exhaustion.
2227
 
 
2260
  repaired, _enum_rows = agent.repair_and_validate_enumeration_contract(
2261
  repaired, section_name=section_name
2262
  )
2263
+ # P3-A4: every model-owned last-mile rewrite must re-enter the same
2264
+ # citation authority + claim-specific semantic Gate before it can be
2265
+ # considered sealable. Otherwise an unrelated adaptive repair could
2266
+ # reintroduce a pre-resolved/unknown/wrong-scope citation after the
2267
+ # earlier Section citation audit had already passed.
2268
+ repaired = agent.audit_and_resolve_citations(
2269
+ repaired, section_name=section_name, section_id=section_id,
2270
+ quarantine=quarantine, allowed_tags=allowed_citation_tags,
2271
+ )
2272
+ adaptive_citation_rows = copy.deepcopy(
2273
+ list(getattr(agent, "_last_citation_semantic_violations", []) or [])
2274
+ )
2275
  rows = self._section_validation_bundle(
2276
  mode, agent, quant_engine, repaired, data_metadata, section_id=section_id,
2277
  section_name=section_name, evidence_contract=evidence_contract, include_critic=False,
2278
+ citation_violations=adaptive_citation_rows, hypothesis_ledger=hypothesis_ledger,
2279
  )
2280
  blocking, advisory = self._partition_section_violations(
2281
  rows, section_name=section_name, mode=mode, exhausted=True
 
5657
  )
5658
  sec_title = sec["section"]
5659
  subsection = sec.get("subsection")
 
 
 
 
5660
  section_id = str(sec.get("section_id") or "")
5661
+ is_quarantined = self._section_forbids_external_citations(sec_title, section_id)
5662
  section_logical_node = self._section_logical_node(
5663
  mode, section_id, sec.get("subsection_id")
5664
  )
 
5684
  elif section_id == "conclusion":
5685
  mode_directive = str(APP_CONFIG.get("conclusion_anchor_directive") or "")
5686
 
5687
+ if is_quarantined:
5688
  citation_rule = (
5689
  "NO EXTERNAL LITERATURE CITATIONS ALLOWED IN THIS UNIT. "
5690
  "Report only documented empirical/qualitative evidence and the interpretation permitted by the section role."
 
6151
  if adaptive_advisory:
6152
  self._record_section_advisories(quality_notes, adaptive_advisory, section_name=display_name)
6153
  if adaptive_blocking:
6154
+ # P3-A4: citation identity/scope authority is a hard
6155
+ # pre-seal invariant. Adaptive completion may retain
6156
+ # reader-facing advisories for unrelated quality
6157
+ # defects, but it may never seal a Section that still
6158
+ # contains an unauthorized/ambiguous citation identity
6159
+ # or a citation outside the frozen unit whitelist.
6160
+ persistent_citation_authority = self._persistent_citation_authority_rows(
6161
+ adaptive_blocking
6162
+ )
6163
+ if persistent_citation_authority:
6164
+ self._checkpoint_section_retry_exhausted(
6165
+ state, section_id=section_id, section_name=display_name,
6166
+ violations=persistent_citation_authority, pct=pct, attempts=section_attempt,
6167
+ )
6168
+ raise RuntimeError(
6169
+ "citation_authority_repair_exhausted:"
6170
+ + str(display_name)
6171
+ + ":"
6172
+ + ",".join(sorted({
6173
+ str((row or {}).get("type") or "citation_authority")
6174
+ for row in persistent_citation_authority if isinstance(row, dict)
6175
+ }))
6176
+ )
6177
  self._record_adaptive_completion_residual(
6178
  quality_notes, adaptive_blocking, section_name=display_name, stage="phase1_semantic_repair"
6179
  )
 
6353
  study_evidence=writer_scoped_data_md, evidence_contract=evidence_packet,
6354
  hypothesis_ledger=hypothesis_ledger, task=sec,
6355
  quote_invocation_plan=quote_invocation_plan, quality_notes=quality_notes,
6356
+ allowed_citation_tags=allowed_section_citation_tags,
6357
+ quarantine=is_quarantined,
6358
+ )
6359
+ persistent_citation_authority = self._persistent_citation_authority_rows(
6360
+ residual_rows
6361
  )
6362
+ if persistent_citation_authority:
6363
+ self._checkpoint_section_retry_exhausted(
6364
+ state, section_id=section_id, section_name=display_name,
6365
+ violations=persistent_citation_authority, pct=pct, attempts=section_attempt,
6366
+ )
6367
+ raise RuntimeError(
6368
+ "citation_authority_repair_exhausted:"
6369
+ + str(display_name)
6370
+ + ":"
6371
+ + ",".join(sorted({
6372
+ str((row or {}).get("type") or "citation_authority")
6373
+ for row in persistent_citation_authority if isinstance(row, dict)
6374
+ }))
6375
+ )
6376
  logging.warning(
6377
  "Adaptive completion accepted section | unit=%s residual=%s",
6378
  display_name, len(residual_rows),
core/pipeline_agent.py CHANGED
@@ -6349,6 +6349,196 @@ 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 +6548,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 +6602,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 +6642,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 high-confidence direct author-year citation forms outside canonical parentheses.
6355
+
6356
+ These forms are never rebound heuristically. They are surfaced as typed
6357
+ section violations so the owning writer can retry with its frozen CITE
6358
+ capability or narrow the unsupported claim. Patterns intentionally avoid
6359
+ bare years and tokens containing digits to reduce false positives for
6360
+ model/version/statistical expressions.
6361
+ """
6362
+ value = str(text or '')
6363
+ surname = r"[A-ZÀ-ÖØ-Þ][A-Za-zÀ-ÖØ-öø-ÿ'’.-]{1,39}"
6364
+ conjunction = r"(?:&|and|AND)"
6365
+ et_al = r"(?:et|Et|ET)\s+(?:al|Al|AL)\."
6366
+ author_group = rf"(?:{surname}(?:\s+{conjunction}\s+{surname})?|{surname}\s+{et_al})"
6367
+ year = r"(?:19|20)\d{2}[a-z]?"
6368
+ patterns = (
6369
+ re.compile(rf"\[(?P<cite>{author_group}\s*,\s*{year})\]"),
6370
+ re.compile(rf"(?<![\w\[])\b(?P<cite>{author_group})\s*\(\s*(?P<year>{year})\s*\)"),
6371
+ )
6372
+ spans = []
6373
+ for pattern in patterns:
6374
+ for match in pattern.finditer(value):
6375
+ candidate = match.group(0).strip()
6376
+ # Canonical SmartPaper markers are handled separately and always
6377
+ # contain the author and year inside the same parentheses.
6378
+ if candidate.startswith('(') and candidate.endswith(')'):
6379
+ continue
6380
+ spans.append((match.start(), match.end(), candidate))
6381
+ # Stable de-duplication for overlapping patterns.
6382
+ unique = []
6383
+ seen = set()
6384
+ for row in sorted(spans, key=lambda item: (item[0], item[1], item[2])):
6385
+ key = (row[0], row[1], row[2])
6386
+ if key not in seen:
6387
+ seen.add(key)
6388
+ unique.append(row)
6389
+ return unique
6390
+ def _normalize_pre_resolved_citation_authority(self, text, *, allowed_tags=None, section_name=''):
6391
+ """Normalize only exact, authoritative pre-resolved markers back to CITE tags.
6392
+
6393
+ The Section Writer is supposed to emit frozen ``[CITE-XX]`` capabilities.
6394
+ A model-emitted reader-facing author-year marker therefore has no provenance
6395
+ until it is matched exactly against the *compiled* citation authority.
6396
+
6397
+ Safe normalization requires all of the following:
6398
+ - exact marker equality after ``compile_citation_identities()``;
6399
+ - authoritative reference metadata;
6400
+ - one distinct source identity for the visible marker;
6401
+ - at least one matching tag inside this manuscript unit's whitelist.
6402
+
6403
+ Anything else becomes a typed violation. No fuzzy author/year matching,
6404
+ metadata synthesis, or silent citation deletion is permitted here.
6405
+ """
6406
+ value = str(text or '')
6407
+ allowed = None if allowed_tags is None else {
6408
+ str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
6409
+ }
6410
+ self.compile_citation_identities()
6411
+
6412
+ authoritative = {}
6413
+ visible_any = {}
6414
+ for tag, metadata in (self.citation_map or {}).items():
6415
+ meta = metadata or {}
6416
+ marker = str(meta.get('in_text') or '').strip()
6417
+ if not marker:
6418
+ continue
6419
+ visible_any.setdefault(marker, []).append(str(tag))
6420
+ if str(meta.get('ref') or '').strip():
6421
+ authoritative.setdefault(marker, []).append(str(tag))
6422
+
6423
+ violations = []
6424
+ replacements = []
6425
+ for marker in list(dict.fromkeys(self._resolved_parenthetical_citations(value))):
6426
+ tags = list(authoritative.get(marker) or [])
6427
+ if not tags:
6428
+ visible_tags = sorted(visible_any.get(marker) or [], key=self._citation_tag_number)
6429
+ violations.append({
6430
+ 'type': 'citation_resolved_marker_without_authority',
6431
+ 'citation': marker,
6432
+ 'tag': visible_tags[0] if len(visible_tags) == 1 else '',
6433
+ 'sentence': marker,
6434
+ 'reason': 'A reader-facing author-year citation was emitted without authoritative reference metadata.',
6435
+ 'repair_instruction': (
6436
+ 'Do not emit author-year citations directly. Use only an authorized CITE tag from this manuscript unit '
6437
+ 'when its evidence directly supports the claim, or narrow/rewrite the claim so no unsupported citation is needed.'
6438
+ ),
6439
+ })
6440
+ continue
6441
+
6442
+ distinct_sources = {
6443
+ self._citation_source_identity(self.citation_map.get(tag) or {})
6444
+ for tag in tags
6445
+ }
6446
+ if len(distinct_sources) != 1:
6447
+ violations.append({
6448
+ 'type': 'citation_resolved_marker_ambiguous',
6449
+ 'citation': marker,
6450
+ 'sentence': marker,
6451
+ 'reason': 'The reader-facing author-year marker maps to multiple distinct authoritative sources.',
6452
+ 'repair_instruction': (
6453
+ 'Do not guess which source was intended. Rewrite this claim using exactly one authorized CITE tag '
6454
+ 'from the frozen evidence plan, or remove/narrow the unsupported citation-dependent claim.'
6455
+ ),
6456
+ })
6457
+ continue
6458
+
6459
+ eligible = tags if allowed is None else [tag for tag in tags if tag in allowed]
6460
+ if not eligible:
6461
+ violations.append({
6462
+ 'type': 'citation_resolved_marker_outside_frozen_section_whitelist',
6463
+ 'citation': marker,
6464
+ 'tag': sorted(tags, key=self._citation_tag_number)[0] if tags else '',
6465
+ 'sentence': marker,
6466
+ 'reason': 'The citation is authoritative globally but is not authorized for this manuscript unit.',
6467
+ 'repair_instruction': (
6468
+ 'Use only citation tags frozen for this manuscript unit. If no eligible source directly supports the claim, '
6469
+ 'narrow or rewrite the claim without external citation support.'
6470
+ ),
6471
+ })
6472
+ continue
6473
+
6474
+ selected = sorted(eligible, key=self._citation_tag_number)[0]
6475
+ replacements.append((marker, selected))
6476
+ self._append_citation_audit(
6477
+ section_name, 'normalize_exact_pre_resolved_marker_to_authoritative_tag',
6478
+ marker, selected, 'Exact compiled marker + authoritative reference + current-unit whitelist.'
6479
+ )
6480
+
6481
+ # Reject other high-confidence author-year forms rather than attempting a
6482
+ # lossy conversion to canonical identity. The bounded Section retry owns
6483
+ # prose repair and claim narrowing.
6484
+ for _start, _end, citation in self._high_confidence_noncanonical_citation_spans(value):
6485
+ violations.append({
6486
+ 'type': 'citation_noncanonical_resolved_marker',
6487
+ 'citation': citation,
6488
+ 'sentence': citation,
6489
+ 'reason': 'A direct author-year citation form bypasses the frozen CITE-tag authority path.',
6490
+ 'repair_instruction': (
6491
+ 'Do not emit narrative or bracketed author-year citations directly. Use one authorized CITE tag '
6492
+ 'from this manuscript unit when directly supported, or rewrite the claim without that citation.'
6493
+ ),
6494
+ })
6495
+
6496
+ for marker, tag in replacements:
6497
+ value = value.replace(marker, tag)
6498
+ return value, violations
6499
+ def _quarantined_unit_citation_violations(self, text):
6500
+ """Return typed citations found in a manuscript unit that forbids literature.
6501
+
6502
+ Conclusion/Appendix-style units are explicitly writer-quarantined from
6503
+ external literature. P3-A4 therefore treats any high-confidence raw or
6504
+ reader-facing citation form as a retry-owned scope violation instead of
6505
+ silently deleting the marker and leaving the surrounding claim intact.
6506
+ Bare years and ordinary numeric/model-version expressions are excluded by
6507
+ the same conservative scanners used by the normal citation path.
6508
+ """
6509
+ value = str(text or '')
6510
+ found = []
6511
+ seen = set()
6512
+
6513
+ for raw in re.findall(r'\[?CITE-\d+\]?', value, flags=re.I):
6514
+ token = raw.upper()
6515
+ key = ('raw', token)
6516
+ if key not in seen:
6517
+ seen.add(key)
6518
+ found.append(token)
6519
+
6520
+ for marker in self._resolved_parenthetical_citations(value):
6521
+ key = ('resolved', marker)
6522
+ if key not in seen:
6523
+ seen.add(key)
6524
+ found.append(marker)
6525
+
6526
+ for _start, _end, citation in self._high_confidence_noncanonical_citation_spans(value):
6527
+ key = ('noncanonical', citation)
6528
+ if key not in seen:
6529
+ seen.add(key)
6530
+ found.append(citation)
6531
+
6532
+ return [{
6533
+ 'type': 'citation_forbidden_in_quarantined_unit',
6534
+ 'citation': citation,
6535
+ 'sentence': citation,
6536
+ 'reason': 'This manuscript unit forbids external literature citations, but a citation form was emitted.',
6537
+ 'repair_instruction': (
6538
+ 'Rewrite this unit without external literature citations. Preserve only documented study evidence, '
6539
+ 'study-owned interpretation, and the section role; do not replace the citation with another source.'
6540
+ ),
6541
+ } for citation in found]
6542
  def audit_and_resolve_citations(
6543
  self, text, section_name='', section_id='', quarantine=False, allowed_tags=None
6544
  ):
 
6548
  )
6549
  value = self.strip_ineligible_current_study_citations(str(text or ''), section_name)
6550
  if quarantine:
6551
+ quarantine_rows = self._quarantined_unit_citation_violations(value)
6552
+ if ownership_rows or quarantine_rows:
6553
+ # Do not silently delete a citation-dependent claim. The owning
6554
+ # Section retry/adaptive-repair path receives a typed violation
6555
+ # and must rewrite the unit without external literature.
6556
+ self._last_citation_semantic_violations = (
6557
+ list(ownership_rows) + list(quarantine_rows)
6558
+ )
6559
+ for row in quarantine_rows:
6560
+ self._append_citation_audit(
6561
+ section_name, 'reject_citation_in_quarantined_unit',
6562
+ str(row.get('citation') or ''), '', str(row.get('reason') or '')
6563
+ )
6564
+ return value
6565
+ return value
6566
+
6567
+ # P3-A4: collect deterministic citation-authority violations separately.
6568
+ # semantic_audit_claim_specific_citations() owns its own last-violation
6569
+ # buffer, so these rows are merged back only after that audit completes.
6570
+ authority_rows = []
6571
  unknown = sorted({f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, re.I)} - set(self.citation_map))
6572
  if unknown:
6573
  for tag in unknown:
6574
+ authority_rows.append({
6575
+ 'type': 'citation_unknown_tag',
6576
+ 'tag': tag,
6577
+ 'sentence': tag,
6578
+ 'reason': 'The manuscript unit emitted a citation capability that does not exist in authoritative citation_map.',
6579
+ 'repair_instruction': (
6580
+ 'Use only citation tags frozen for this manuscript unit, or narrow/rewrite the claim so no unsupported citation is needed.'
6581
+ ),
6582
+ })
6583
  value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
6584
+ self._append_citation_audit(section_name, 'reject_unknown_tags_for_section_retry', text, value, ','.join(unknown))
6585
+
6586
  allowed = None if allowed_tags is None else {
6587
  str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
6588
  }
 
6602
  self._append_citation_audit(
6603
  section_name, 'reject_outside_frozen_section_whitelist', text, value, tag
6604
  )
6605
+
6606
+ # P3-A4 closes the resolved-marker bypass before section sealing. Exact,
6607
+ # unambiguous, current-unit-authorized markers are normalized back to raw
6608
+ # capabilities so the existing claim-specific semantic audit still owns
6609
+ # claim/evidence adjudication. All other forms become typed violations.
6610
+ value, resolved_authority_rows = self._normalize_pre_resolved_citation_authority(
6611
+ value, allowed_tags=allowed, section_name=section_name
6612
+ )
6613
+ authority_rows.extend(resolved_authority_rows)
6614
+
6615
  has_citations = bool(re.search(r'CITE-\d+', value, re.I))
6616
  violations = self.find_multi_citation_violations(value)
6617
  if has_citations and self.citation_semantic_audit.get('enabled', True):
 
6642
  value = self._local_citation_recovery(value, section_name, retain_lexical=False)
6643
  elif violations:
6644
  value = self._local_citation_recovery(value, section_name)
6645
+
6646
+ pre_semantic_rows = list(ownership_rows) + list(outside_rows) + list(authority_rows)
6647
+ if pre_semantic_rows:
6648
  self._last_citation_semantic_violations = (
6649
+ pre_semantic_rows + list(self._last_citation_semantic_violations or [])
 
 
6650
  )
6651
+
6652
+ # P3-A4 reliability rule: once deterministic identity/scope authority has
6653
+ # already rejected this attempt, return it to the owning bounded Section
6654
+ # retry instead of letting the legacy multi-source hard exception preempt
6655
+ # typed recovery. The rejected bytes can never be sealed because runner
6656
+ # treats these rows as a hard pre-seal invariant.
6657
+ if outside_rows or authority_rows:
6658
+ return value
6659
+
6660
  if self.find_multi_citation_violations(value):
6661
  value = self._local_citation_recovery(value, section_name)
6662
  if self.find_multi_citation_violations(value):