P3-A4 citation authority repair

#151
Files changed (2) hide show
  1. core/generation_runner.py +0 -0
  2. core/pipeline_agent.py +284 -322
core/generation_runner.py CHANGED
The diff for this file is too large to render. See raw diff
 
core/pipeline_agent.py CHANGED
@@ -1640,117 +1640,6 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1640
  return cleaned
1641
 
1642
 
1643
- @staticmethod
1644
- def _english_reader_surface_has_foreign_script(value):
1645
- """Return True only for scripts that cannot remain in an English reader-facing surface.
1646
-
1647
- User intent may be entered in Chinese, Russian, or Kazakh, but the English
1648
- manuscript projection must not leak Han or Cyrillic source text. Latin
1649
- acronyms, numerals, punctuation, and ordinary English remain untouched.
1650
- """
1651
- return bool(re.search(r'[\u3400-\u9fff\u3040-\u30ff\u0400-\u052f]', str(value or '')))
1652
-
1653
- @classmethod
1654
- def validate_reader_facing_english_projection(cls, projection, *, expected_keyword_count=None):
1655
- """Validate a language projection without changing the user's semantic authority."""
1656
- record = dict(projection or {})
1657
- title = str(record.get('title') or record.get('english_title') or '').strip()
1658
- raw_keywords = record.get('keywords')
1659
- if raw_keywords is None:
1660
- raw_keywords = record.get('english_keywords')
1661
- keywords = [str(item).strip() for item in (raw_keywords or []) if str(item).strip()]
1662
- if not title:
1663
- raise DeepSeekRequestError('English reader-facing title is empty.')
1664
- if expected_keyword_count is not None and len(keywords) != int(expected_keyword_count):
1665
- raise DeepSeekRequestError(
1666
- f'English reader-facing keyword cardinality changed: expected={int(expected_keyword_count)} observed={len(keywords)}.'
1667
- )
1668
- combined = ' '.join([title] + keywords)
1669
- if cls._english_reader_surface_has_foreign_script(combined):
1670
- raise DeepSeekRequestError('English reader-facing title/keywords contain untranslated Han or Cyrillic source text.')
1671
- return {'title': title, 'keywords': keywords}
1672
-
1673
- def normalize_reader_facing_front_matter_to_english(self, topic, keywords):
1674
- """Create a faithful English language projection of user Title/Keywords.
1675
-
1676
- The original user strings remain semantic authority in the study contract.
1677
- This method is presentation-only: English input is passed through without a
1678
- model call; non-English/mixed-script input is translated, never optimized.
1679
- Keyword cardinality and order are invariant.
1680
- """
1681
- source_title = str(topic or '').strip()
1682
- source_keywords = parse_keyword_terms(keywords, explicit_user=True)
1683
- if not source_title:
1684
- raise DeepSeekRequestError('User research title is empty before English language projection.')
1685
-
1686
- source_surface = ' '.join([source_title] + source_keywords)
1687
- if not self._english_reader_surface_has_foreign_script(source_surface):
1688
- return {
1689
- 'title': source_title,
1690
- 'keywords': list(source_keywords),
1691
- 'translation_applied': False,
1692
- }
1693
-
1694
- indexed_keywords = [
1695
- {'index': index, 'text': value}
1696
- for index, value in enumerate(source_keywords, start=1)
1697
- ]
1698
- payload = {'title': source_title, 'keywords': indexed_keywords}
1699
- base_prompt = f"""Translate ONLY the supplied user research Title and Keywords into academic English.
1700
- Return JSON only: {{"title":"...","keywords":["..."]}}.
1701
-
1702
- LANGUAGE-AUTHORITY CONTRACT:
1703
- 1. This is translation, not title optimization, rewriting, topic expansion, or keyword generation.
1704
- 2. Preserve the complete research meaning: focal constructs, direction/relationship wording, mediator/moderator roles, population/context, method qualifiers, acronyms, numbers, and scope.
1705
- 3. Do not add, delete, merge, split, reorder, generalize, specialize, or reinterpret any keyword. Return exactly {len(source_keywords)} keywords in the supplied order.
1706
- 4. Preserve existing English technical identities and acronyms. Translate/transliterate Han or Cyrillic reader-facing text so the returned Title/Keywords contain no Chinese, Russian, or Kazakh script.
1707
- 5. Do not mention the translation process and do not wrap JSON in Markdown.
1708
-
1709
- <SOURCE_USER_FRONT_MATTER>
1710
- {json.dumps(payload, ensure_ascii=False)}
1711
- </SOURCE_USER_FRONT_MATTER>"""
1712
- current_prompt = base_prompt
1713
- last_error = None
1714
- for local_attempt in range(2):
1715
- response = self.call_deepseek(
1716
- 'You are a precise academic translator. Preserve research semantics exactly and change only language surface.',
1717
- current_prompt,
1718
- max_tokens=max(700, min(1600, 500 + len(source_keywords) * 90)),
1719
- temp=0.0,
1720
- model_role='writing',
1721
- thinking=False,
1722
- allow_fallback=False,
1723
- response_format={'type': 'json_object'},
1724
- logical_node='front_matter_writing',
1725
- )
1726
- try:
1727
- parsed = self._extract_json_from_text(response)
1728
- if not isinstance(parsed, dict):
1729
- raise DeepSeekRequestError('English front-matter language projection was not valid JSON.')
1730
- candidate = self.validate_reader_facing_english_projection(
1731
- parsed, expected_keyword_count=len(source_keywords)
1732
- )
1733
- return {
1734
- 'title': candidate['title'],
1735
- 'keywords': candidate['keywords'],
1736
- 'translation_applied': True,
1737
- }
1738
- except DeepSeekRequestError as exc:
1739
- last_error = exc
1740
- if local_attempt >= 1:
1741
- raise
1742
- logging.warning(
1743
- 'English front-matter language projection retry | attempt=%s/2 | reason=%s',
1744
- local_attempt + 1, self._safe_source_error(exc),
1745
- )
1746
- current_prompt = base_prompt + (
1747
- '\n\nLOCAL VALIDATION RETRY: The prior response violated the language/cardinality contract. '
1748
- 'Return a fresh complete JSON object only. Translate the same Title and the same indexed Keywords '
1749
- 'faithfully into English, preserve keyword order/count exactly, and remove all Han/Cyrillic source text. '
1750
- 'Do not optimize or rewrite the research intent.'
1751
- )
1752
- raise DeepSeekRequestError(f'English front-matter language projection failed: {last_error}')
1753
-
1754
  @staticmethod
1755
  def _front_matter_has_citation(value):
1756
  text = str(value or '')
@@ -1790,8 +1679,6 @@ LANGUAGE-AUTHORITY CONTRACT:
1790
  word_count = cls._working_abstract_word_count(text)
1791
  if cls._front_matter_has_citation(text):
1792
  raise DeepSeekRequestError('Working Abstract must not contain citations.')
1793
- if cls._english_reader_surface_has_foreign_script(text):
1794
- raise DeepSeekRequestError('Working Abstract contains untranslated Han or Cyrillic source text.')
1795
  forbidden = (
1796
  ('RESULT_ASSERTION', r'\bwe\s+(?:find|found|show|demonstrate|observe|establish)\b'),
1797
  ('RESULT_ASSERTION', r'\b(?:results?|findings?)\s+(?:show|indicate|reveal|demonstrate|confirm|support)\b'),
@@ -1988,28 +1875,24 @@ LANGUAGE-AUTHORITY CONTRACT:
1988
  return issues
1989
 
1990
 
1991
- def reconcile_quantitative_reader_front_matter(
1992
- self, *, topic, keywords, contract, manuscript="", reader_title=None, reader_keywords=None
1993
- ):
1994
- """Preserve source semantic authority while allowing a faithful English surface.
1995
 
1996
- ``user_research_intent`` remains the immutable semantic authority. When
1997
- ``reader_title``/``reader_keywords`` are supplied they are a language-only
1998
- English projection of that same authority; they may change script/language
1999
- but not constructs, relations, scope, order, or keyword cardinality.
 
2000
  """
2001
  record = dict(contract or {})
2002
  intent = dict(record.get("user_research_intent") or {})
2003
  policy = keyword_policy_from_contract(record, fallback_keywords=keywords)
2004
  source_topic = str(intent.get("source_topic") or record.get("source_topic") or topic or "").strip()
2005
  source_keywords = list(intent.get("source_keywords") or [])
2006
- reader_source_topic = str(reader_title or source_topic or topic or "").strip()
2007
- reader_source_keywords = parse_keyword_terms(reader_keywords, explicit_user=True) if reader_keywords is not None else list(source_keywords)
2008
  if str(policy.get("authority_source") or "").upper() == "USER_EXPLICIT" and source_keywords:
2009
- authoritative_reader = reader_source_keywords or source_keywords
2010
  return {
2011
- "title": reader_source_topic,
2012
- "keywords": apply_keyword_policy(authoritative_reader, policy, authoritative=authoritative_reader),
2013
  }
2014
 
2015
  # No explicit user keyword authority: retain user-authoritative title when
@@ -2027,7 +1910,7 @@ LANGUAGE-AUTHORITY CONTRACT:
2027
  logging.warning("Front-matter semantic contract missing frozen constructs; using degraded fallback.")
2028
  fallback = self._quantitative_front_matter_fallback(record)
2029
  if intent and source_topic:
2030
- fallback["title"] = reader_source_topic
2031
  return fallback
2032
 
2033
  source_keywords = list(record.get("source_keywords") or [])
@@ -2116,7 +1999,7 @@ A contextual/domain term is allowed. Mark INVALID only when the proposal makes a
2116
  extra = [str(item).strip() for item in ((critic or {}).get("unmeasured_focal_constructs") or []) if str(item).strip()]
2117
  missing = [str(item).strip() for item in ((critic or {}).get("missing_frozen_constructs") or []) if str(item).strip()]
2118
  if not invalid and not extra and not missing:
2119
- resolved_title = reader_source_topic if intent else re.sub(r"\s+", " ", str(candidate.get("title") or "")).strip()
2120
  return {
2121
  "title": resolved_title,
2122
  "keywords": apply_keyword_policy(candidate.get("keywords") or [], policy),
@@ -2130,11 +2013,7 @@ A contextual/domain term is allowed. Mark INVALID only when the proposal makes a
2130
 
2131
  fallback = self._quantitative_front_matter_fallback(record)
2132
  if intent and source_topic:
2133
- fallback["title"] = reader_source_topic
2134
- if str(policy.get("authority_source") or "").upper() == "USER_EXPLICIT" and reader_source_keywords:
2135
- fallback["keywords"] = apply_keyword_policy(
2136
- reader_source_keywords, policy, authoritative=reader_source_keywords
2137
- )
2138
  logging.warning("Adaptive front-matter reconciliation exhausted; using deterministic degraded fallback instead of failing manuscript generation.")
2139
  return fallback
2140
 
@@ -2149,7 +2028,7 @@ A contextual/domain term is allowed. Mark INVALID only when the proposal makes a
2149
  prompt = f"""Create WORKING front matter for an empirical {str(mode).upper()}-oriented manuscript before body drafting.
2150
  Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
2151
  Aim for approximately 120-180 English words, have no citations, and function only as a continuity anchor. Word count is guidance, not a failure condition; prioritize Conciseness, Accuracy, and Rigor over mechanical compliance. It may state the research problem, theoretical tension, focal concepts, question/hypothesis architecture, design or method class, and intended contribution domain. It MUST NOT state coefficients, p-values, fit statistics, sample statistics unless already indispensable to define the design, supported/unsupported hypotheses, result direction, substantive findings, or a conclusion that has not yet been frozen. Do not predict findings.
2152
- Preserve the supplied English keyword identities and research scope. They are a faithful language projection of the user input. Do not silently replace, delete, reorder, or reinterpret an explicit construct/relation because direct literature support is sparse. Do not translate them back into the source input language.
2153
  TOPIC: {topic}
2154
  USER KEYWORDS: {keywords}
2155
  <PROVISIONAL_RESEARCH_LOGIC>
@@ -2186,7 +2065,7 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
2186
  prompt = f"""Write FINAL English front matter for this completed {str(mode).upper()} manuscript.
2187
  Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
2188
  Aim for a concise Abstract of roughly 120-200 words, use no citations, and accurately state the research problem, validated design, main frozen finding(s), and contribution. Word count is guidance, not a failure condition; prioritize Conciseness, Accuracy, and Rigor. Include only the minimum numerical evidence required; do not list coefficients, p-values, and fit statistics unless one value is indispensable. Do not infer beyond the frozen evidence or completed manuscript.
2189
- Preserve the supplied English keyword identities and research scope. They are a faithful language projection of the user input. Do not silently replace, delete, reorder, or reinterpret an explicit construct/relation because direct literature support is sparse. Do not translate them back into the source input language.
2190
  TOPIC: {topic}
2191
  USER KEYWORDS: {keywords}
2192
  <FROZEN_RESEARCH_EVIDENCE>
@@ -2206,8 +2085,6 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
2206
  abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', str(parsed.get('abstract') or '')).strip()
2207
  if self._front_matter_has_citation(abstract):
2208
  raise DeepSeekRequestError('Final Abstract must not contain citations.')
2209
- if self._english_reader_surface_has_foreign_script(abstract):
2210
- raise DeepSeekRequestError('Final Abstract contains untranslated Han or Cyrillic source text.')
2211
  meta_findings = self.inspect_authorial_ai_meta(abstract)
2212
  if meta_findings:
2213
  raise DeepSeekRequestError('Final Abstract contains high-confidence authorial AI-writing meta-commentary.')
@@ -3054,17 +2931,8 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
3054
  future_lane[executor.submit(self._s2_worker, query, 0)] = 'method_authority'
3055
  future_lane[executor.submit(self._oa_worker, query, 1, "language:en,type:article,is_paratext:false")] = 'method_authority'
3056
  for future in concurrent.futures.as_completed(future_lane):
 
3057
  lane = future_lane[future]
3058
- try:
3059
- results = future.result()
3060
- except PipelineCancelledError:
3061
- raise
3062
- except Exception as exc:
3063
- logging.warning(
3064
- "Delivery-first METHOD_AUTHORITY source skipped | lane=%s error=%s",
3065
- lane, type(exc).__name__,
3066
- )
3067
- continue
3068
  with self.lock:
3069
  for res in results:
3070
  result_keys = self._reference_keys(res)
@@ -3098,44 +2966,28 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
3098
  sys_prompt = """You are an expert academic librarian. Generate exactly 3 concise English search queries.
3099
  [CRITICAL SEARCH QUERY RULES]
3100
  1. DO NOT use complex, deeply nested boolean logic (e.g., Avoid `(A OR B) AND (C OR D)`).
3101
- 2. DO NOT use excessive quotation marks for long phrases.
3102
- 3. Output simple, space-separated keyword combinations or simple AND logic.
3103
  Good: "educational chatbot" "recommendation system" context-aware
3104
  Bad: ("educational chatbot" OR "pedagogical agent") AND ("recommendation system")
3105
  4. Keep the query strictly UNDER 5 terms to avoid triggering server API timeouts.
3106
  Return ONLY a valid JSON array of strings."""
3107
  user_prompt = f"Topic: {topic}\nKeywords: {keywords}"
3108
  for attempt in range(3):
3109
- try:
3110
- queries = self._extract_json_from_text(self.call_deepseek(
3111
- sys_prompt,
3112
- user_prompt,
3113
- temp=0.1 + attempt * 0.1,
3114
- model_role="fast",
3115
- logical_node='search_jit',
3116
- ))
3117
- except PipelineCancelledError:
3118
- raise
3119
- except Exception as exc:
3120
- logging.warning(
3121
- "Delivery-first search-query generation fallback | attempt=%s/3 error=%s",
3122
- attempt + 1, type(exc).__name__,
3123
- )
3124
- queries = []
3125
  if queries and isinstance(queries, list) and len(queries) > 0:
3126
  final_queries = [str(q).strip() for q in queries if self._is_english_dominant(str(q).strip())]
3127
- if final_queries:
3128
  return final_queries[:3]
3129
  time.sleep(1)
3130
-
3131
- clean_chunks = [
3132
- c.strip() for c in re.findall(r'[a-zA-Z0-9\s\-]{4,}', f"{topic} {keywords}")
3133
- if self._is_english_dominant(c.strip())
3134
- ]
3135
- if clean_chunks:
3136
- logging.warning(
3137
- "Delivery-first search-query provider exhausted; using deterministic English user-text query fallback."
3138
- )
3139
  return clean_chunks[:3] if clean_chunks else []
3140
 
3141
  @staticmethod
@@ -3566,18 +3418,7 @@ Return ONLY a valid JSON array of strings."""
3566
  for future in concurrent.futures.as_completed(future_sources):
3567
  self._check_cancelled()
3568
  source = future_sources[future]
3569
- try:
3570
- rows = future.result()
3571
- except PipelineCancelledError:
3572
- raise
3573
- except Exception as exc:
3574
- self._record_source_error(source, 'RETRIEVAL_FUTURE_ERROR', detail=exc)
3575
- logging.warning(
3576
- "Delivery-first literature source future skipped | source=%s error=%s",
3577
- source, type(exc).__name__,
3578
- )
3579
- rows = []
3580
- collected[source].extend(rows)
3581
  if progress_callback:
3582
  progress_callback()
3583
 
@@ -3586,24 +3427,15 @@ Return ONLY a valid JSON array of strings."""
3586
  if phase == 0:
3587
  terms = self.generate_search_queries(topic, keywords)
3588
  else:
3589
- try:
3590
- response = self.call_deepseek(
3591
- 'You are an academic librarian. Return only a JSON array with three new concise English search queries, each under five terms.',
3592
- f"Topic: {topic}\nKeywords: {keywords}\nUsed: {sorted(all_used_terms)}",
3593
- temp=0.4,
3594
- model_role='fast',
3595
- logical_node='search_jit',
3596
- )
3597
- parsed = self._extract_json_from_text(response)
3598
- terms = parsed if isinstance(parsed, list) else []
3599
- except PipelineCancelledError:
3600
- raise
3601
- except Exception as exc:
3602
- logging.warning(
3603
- 'Delivery-first auxiliary search-query phase skipped | phase=%s error=%s',
3604
- phase, type(exc).__name__,
3605
- )
3606
- terms = []
3607
 
3608
  normalized_terms = []
3609
  for value in terms:
@@ -5256,84 +5088,6 @@ INPUT:
5256
  return True
5257
  return False
5258
 
5259
- @staticmethod
5260
- def _has_explicit_current_qualitative_study_anchor(sentence):
5261
- """Identify an explicit current-study owner even in a cited sentence.
5262
-
5263
- A method citation may justify a procedure, but it must not make this
5264
- manuscript's own empirical/procedural clause externally owned. Keep
5265
- this narrower than the general qualitative ownership heuristic so a
5266
- cited phrase such as ``the study conducted interviews`` is not
5267
- accidentally reclassified as the current manuscript.
5268
- """
5269
- value = str(sentence or "")
5270
- return bool(re.search(
5271
- r"(?i)\b(?:we|our\s+study|this\s+study|the\s+present\s+study|"
5272
- r"research\s+team|our\s+data|our\s+analysis|our\s+interviews?|"
5273
- r"our\s+focus\s+groups?|our\s+observations?|in\s+this\s+study|"
5274
- r"in\s+our\s+study)\b",
5275
- value,
5276
- ))
5277
-
5278
- @staticmethod
5279
- def _normalize_qualitative_analytic_design_identity(value):
5280
- folded = re.sub(r"\s+", " ", str(value or "")).strip().casefold()
5281
- if not folded:
5282
- return ""
5283
- if "gioia" in folded:
5284
- return "gioia"
5285
- if "grounded theory" in folded:
5286
- return "grounded_theory"
5287
- if "comparative case" in folded:
5288
- return "comparative_case"
5289
- if "longitudinal" in folded and "qualitative" in folded:
5290
- return "longitudinal_qualitative"
5291
- if "thematic" in folded:
5292
- return "thematic_process"
5293
- if "process analysis" in folded:
5294
- return "process_analysis"
5295
- return folded
5296
-
5297
- @classmethod
5298
- def _performed_qualitative_analytic_design_identity(cls, sentence):
5299
- """Return a performed current-study qualitative design, if explicit.
5300
-
5301
- This is a prose-level evidence guard, not a new method router. It
5302
- prevents a cited method name from silently replacing the analytic design
5303
- frozen in the qualitative evidence packet.
5304
- """
5305
- value = re.sub(r"\s+", " ", str(sentence or "")).strip()
5306
- if not value or not cls._has_explicit_current_qualitative_study_anchor(value):
5307
- return ""
5308
- candidates = (
5309
- ("gioia", r"\bGioia(?:-style)?(?:\s+inductive\s+coding|\s+methodology|\s+method|\s+approach)?\b"),
5310
- ("grounded_theory", r"\bgrounded\s+theory\b"),
5311
- ("comparative_case", r"\bcomparative\s+case\s+analysis\b"),
5312
- ("longitudinal_qualitative", r"\blongitudinal\s+qualitative\s+analysis\b"),
5313
- ("process_analysis", r"\bprocess\s+analysis\b"),
5314
- ("thematic_process", r"\b(?:iterative\s+)?thematic(?:/process)?\s+analysis\b|\bthematic\s+analysis\b"),
5315
- )
5316
- for identity, pattern in candidates:
5317
- match = re.search(pattern, value, flags=re.I)
5318
- if not match:
5319
- continue
5320
- before = value[max(0, match.start() - 120):match.start()]
5321
- if re.search(
5322
- r"(?i)\b(?:did\s+not|does\s+not|do\s+not|was\s+not|were\s+not|"
5323
- r"not|without|never)\b[^.;:]{0,45}$",
5324
- before,
5325
- ):
5326
- continue
5327
- full_before = value[:match.start()]
5328
- if re.search(
5329
- r"(?i)\b(?:use|uses|used|employ|employs|employed|apply|applies|applied|"
5330
- r"adopt|adopts|adopted|follow|follows|followed|conduct|conducts|conducted|"
5331
- r"analy[sz]e|analy[sz]es|analy[sz]ed|code|codes|coded)\b[^.;:]{0,180}$",
5332
- full_before,
5333
- ):
5334
- return identity
5335
- return ""
5336
-
5337
  @classmethod
5338
  def _is_study_owned_qualitative_claim(cls, sentence, section_name=""):
5339
  """Conservatively identify only this manuscript's empirical/procedural claims.
@@ -5348,11 +5102,6 @@ INPUT:
5348
  return False
5349
  if re.match(r'^["“‘\']', stripped) or re.search(r"\bQUOTE-\d+\b", value, flags=re.I):
5350
  return False
5351
- # Explicit current-study ownership dominates an appended method or
5352
- # theory citation. The cited source may justify the procedure, but it
5353
- # cannot own the statement that *this* study performed it.
5354
- if cls._has_explicit_current_qualitative_study_anchor(value):
5355
- return True
5356
  if cls._has_external_literature_marker(value):
5357
  return False
5358
  if re.search(r"(?i)\b(?:prior|previous|earlier|existing)\s+(?:research|stud(?:y|ies)|literature)\b", value):
@@ -5406,9 +5155,6 @@ INPUT:
5406
  "ethics": [str(x or "").strip().casefold() for x in (facts.get("ethics_procedures") or []) if str(x or "").strip()],
5407
  "reflexivity": [str(x or "").strip().casefold() for x in (facts.get("reflexivity_procedures") or []) if str(x or "").strip()],
5408
  }
5409
- authorized_design = cls._normalize_qualitative_analytic_design_identity(
5410
- facts.get("analytic_design") or facts.get("analysis_method")
5411
- )
5412
  procedure_patterns = {
5413
  "trustworthiness": r"\bmember\s+check(?:ing|s)?\b|\bpeer\s+debrief(?:ing|ed)?\b|\baudit\s+trail\b|\bexternal\s+audit\b",
5414
  "ethics": r"\binformed\s+consent\b|\bethics?\s+approval\b|\binstitutional\s+review\s+board\b|\bIRB\b|\bapproval\s+(?:number|code)\b",
@@ -5445,18 +5191,6 @@ INPUT:
5445
  if not core.strip() or not cls._is_study_owned_qualitative_claim(core, section_name):
5446
  continue
5447
 
5448
- observed_design = cls._performed_qualitative_analytic_design_identity(core)
5449
- if observed_design and authorized_design and observed_design != authorized_design:
5450
- sig = ("analytic_design", observed_design)
5451
- if sig not in seen:
5452
- seen.add(sig)
5453
- violations.append({
5454
- "type":"unsupported_qualitative_analytic_design",
5455
- "observed":observed_design,
5456
- "expected":authorized_design,
5457
- "preview":core.strip()[:180],
5458
- })
5459
-
5460
  # Check every explicit procedure mention; do not stop after the first.
5461
  for category, pattern in procedure_patterns.items():
5462
  for match in re.finditer(pattern, core, flags=re.I):
@@ -5519,9 +5253,6 @@ INPUT:
5519
  "ethics": [str(x or "").strip() for x in (facts.get("ethics_procedures") or []) if str(x or "").strip()],
5520
  "reflexivity": [str(x or "").strip() for x in (facts.get("reflexivity_procedures") or []) if str(x or "").strip()],
5521
  }
5522
- authorized_design = cls._normalize_qualitative_analytic_design_identity(
5523
- facts.get("analytic_design") or facts.get("analysis_method")
5524
- )
5525
  allowed_by_actor = {}
5526
  canonical_relations = []
5527
  canonical_by_actor = {}
@@ -5556,16 +5287,6 @@ INPUT:
5556
  lower = core.casefold()
5557
  if not core.strip() or not cls._is_study_owned_qualitative_claim(core, section_name):
5558
  continue
5559
- observed_design = cls._performed_qualitative_analytic_design_identity(core)
5560
- if observed_design and authorized_design and observed_design != authorized_design:
5561
- replacements.append((start, end, ""))
5562
- diagnostics.append({
5563
- "type":"unsupported_qualitative_analytic_design_removed",
5564
- "observed":observed_design,
5565
- "expected":authorized_design,
5566
- "preview":core.strip()[:180],
5567
- })
5568
- continue
5569
  removed = False
5570
  for category, pattern in procedure_patterns.items():
5571
  procedure_match = re.search(pattern, core, flags=re.I)
@@ -6349,6 +6070,199 @@ 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 +6272,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 +6326,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 +6366,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):
 
1640
  return cleaned
1641
 
1642
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1643
  @staticmethod
1644
  def _front_matter_has_citation(value):
1645
  text = str(value or '')
 
1679
  word_count = cls._working_abstract_word_count(text)
1680
  if cls._front_matter_has_citation(text):
1681
  raise DeepSeekRequestError('Working Abstract must not contain citations.')
 
 
1682
  forbidden = (
1683
  ('RESULT_ASSERTION', r'\bwe\s+(?:find|found|show|demonstrate|observe|establish)\b'),
1684
  ('RESULT_ASSERTION', r'\b(?:results?|findings?)\s+(?:show|indicate|reveal|demonstrate|confirm|support)\b'),
 
1875
  return issues
1876
 
1877
 
1878
+ def reconcile_quantitative_reader_front_matter(self, *, topic, keywords, contract, manuscript=""):
1879
+ """Preserve R5 user intent while retaining legacy contract reconciliation.
 
 
1880
 
1881
+ Contracts that carry ``user_research_intent`` are authoritative: the
1882
+ finalizer must not repair an upstream design mismatch by silently
1883
+ renaming the user study. Older contracts without that explicit R5
1884
+ authority retain the pre-R5 bounded reconciliation path so historical
1885
+ checkpoints and compatibility behavior remain stable.
1886
  """
1887
  record = dict(contract or {})
1888
  intent = dict(record.get("user_research_intent") or {})
1889
  policy = keyword_policy_from_contract(record, fallback_keywords=keywords)
1890
  source_topic = str(intent.get("source_topic") or record.get("source_topic") or topic or "").strip()
1891
  source_keywords = list(intent.get("source_keywords") or [])
 
 
1892
  if str(policy.get("authority_source") or "").upper() == "USER_EXPLICIT" and source_keywords:
 
1893
  return {
1894
+ "title": source_topic,
1895
+ "keywords": apply_keyword_policy(source_keywords, policy, authoritative=source_keywords),
1896
  }
1897
 
1898
  # No explicit user keyword authority: retain user-authoritative title when
 
1910
  logging.warning("Front-matter semantic contract missing frozen constructs; using degraded fallback.")
1911
  fallback = self._quantitative_front_matter_fallback(record)
1912
  if intent and source_topic:
1913
+ fallback["title"] = source_topic
1914
  return fallback
1915
 
1916
  source_keywords = list(record.get("source_keywords") or [])
 
1999
  extra = [str(item).strip() for item in ((critic or {}).get("unmeasured_focal_constructs") or []) if str(item).strip()]
2000
  missing = [str(item).strip() for item in ((critic or {}).get("missing_frozen_constructs") or []) if str(item).strip()]
2001
  if not invalid and not extra and not missing:
2002
+ resolved_title = source_topic if intent else re.sub(r"\s+", " ", str(candidate.get("title") or "")).strip()
2003
  return {
2004
  "title": resolved_title,
2005
  "keywords": apply_keyword_policy(candidate.get("keywords") or [], policy),
 
2013
 
2014
  fallback = self._quantitative_front_matter_fallback(record)
2015
  if intent and source_topic:
2016
+ fallback["title"] = source_topic
 
 
 
 
2017
  logging.warning("Adaptive front-matter reconciliation exhausted; using deterministic degraded fallback instead of failing manuscript generation.")
2018
  return fallback
2019
 
 
2028
  prompt = f"""Create WORKING front matter for an empirical {str(mode).upper()}-oriented manuscript before body drafting.
2029
  Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
2030
  Aim for approximately 120-180 English words, have no citations, and function only as a continuity anchor. Word count is guidance, not a failure condition; prioritize Conciseness, Accuracy, and Rigor over mechanical compliance. It may state the research problem, theoretical tension, focal concepts, question/hypothesis architecture, design or method class, and intended contribution domain. It MUST NOT state coefficients, p-values, fit statistics, sample statistics unless already indispensable to define the design, supported/unsupported hypotheses, result direction, substantive findings, or a conclusion that has not yet been frozen. Do not predict findings.
2031
+ Preserve the user keyword identities and research scope. Do not silently replace, delete, or reinterpret an explicit construct/relation because direct literature support is sparse. The user-supplied keyword list is authoritative for reader-facing keyword identity; wording changes belong only to target-language translation.
2032
  TOPIC: {topic}
2033
  USER KEYWORDS: {keywords}
2034
  <PROVISIONAL_RESEARCH_LOGIC>
 
2065
  prompt = f"""Write FINAL English front matter for this completed {str(mode).upper()} manuscript.
2066
  Return JSON only: {{\"abstract\":\"...\",\"keywords\":[\"...\"]}}.
2067
  Aim for a concise Abstract of roughly 120-200 words, use no citations, and accurately state the research problem, validated design, main frozen finding(s), and contribution. Word count is guidance, not a failure condition; prioritize Conciseness, Accuracy, and Rigor. Include only the minimum numerical evidence required; do not list coefficients, p-values, and fit statistics unless one value is indispensable. Do not infer beyond the frozen evidence or completed manuscript.
2068
+ Preserve the user keyword identities and research scope. Do not silently replace, delete, or reinterpret an explicit construct/relation because direct literature support is sparse. The user-supplied keyword list is authoritative for reader-facing keyword identity; wording changes belong only to target-language translation.
2069
  TOPIC: {topic}
2070
  USER KEYWORDS: {keywords}
2071
  <FROZEN_RESEARCH_EVIDENCE>
 
2085
  abstract = re.sub(r'(?im)^#+\s*abstract\s*', '', str(parsed.get('abstract') or '')).strip()
2086
  if self._front_matter_has_citation(abstract):
2087
  raise DeepSeekRequestError('Final Abstract must not contain citations.')
 
 
2088
  meta_findings = self.inspect_authorial_ai_meta(abstract)
2089
  if meta_findings:
2090
  raise DeepSeekRequestError('Final Abstract contains high-confidence authorial AI-writing meta-commentary.')
 
2931
  future_lane[executor.submit(self._s2_worker, query, 0)] = 'method_authority'
2932
  future_lane[executor.submit(self._oa_worker, query, 1, "language:en,type:article,is_paratext:false")] = 'method_authority'
2933
  for future in concurrent.futures.as_completed(future_lane):
2934
+ results = future.result()
2935
  lane = future_lane[future]
 
 
 
 
 
 
 
 
 
 
2936
  with self.lock:
2937
  for res in results:
2938
  result_keys = self._reference_keys(res)
 
2966
  sys_prompt = """You are an expert academic librarian. Generate exactly 3 concise English search queries.
2967
  [CRITICAL SEARCH QUERY RULES]
2968
  1. DO NOT use complex, deeply nested boolean logic (e.g., Avoid `(A OR B) AND (C OR D)`).
2969
+ 2. DO NOT use excessive quotation marks for long phrases.
2970
+ 3. Output simple, space-separated keyword combinations or simple AND logic.
2971
  Good: "educational chatbot" "recommendation system" context-aware
2972
  Bad: ("educational chatbot" OR "pedagogical agent") AND ("recommendation system")
2973
  4. Keep the query strictly UNDER 5 terms to avoid triggering server API timeouts.
2974
  Return ONLY a valid JSON array of strings."""
2975
  user_prompt = f"Topic: {topic}\nKeywords: {keywords}"
2976
  for attempt in range(3):
2977
+ queries = self._extract_json_from_text(self.call_deepseek(
2978
+ sys_prompt,
2979
+ user_prompt,
2980
+ temp=0.1 + attempt * 0.1,
2981
+ model_role="fast",
2982
+ logical_node='search_jit',
2983
+ ))
 
 
 
 
 
 
 
 
 
2984
  if queries and isinstance(queries, list) and len(queries) > 0:
2985
  final_queries = [str(q).strip() for q in queries if self._is_english_dominant(str(q).strip())]
2986
+ if final_queries:
2987
  return final_queries[:3]
2988
  time.sleep(1)
2989
+
2990
+ clean_chunks = [c.strip() for c in re.findall(r'[a-zA-Z0-9\s\-]{4,}', f"{topic} {keywords}") if self._is_english_dominant(c.strip())]
 
 
 
 
 
 
 
2991
  return clean_chunks[:3] if clean_chunks else []
2992
 
2993
  @staticmethod
 
3418
  for future in concurrent.futures.as_completed(future_sources):
3419
  self._check_cancelled()
3420
  source = future_sources[future]
3421
+ collected[source].extend(future.result())
 
 
 
 
 
 
 
 
 
 
 
3422
  if progress_callback:
3423
  progress_callback()
3424
 
 
3427
  if phase == 0:
3428
  terms = self.generate_search_queries(topic, keywords)
3429
  else:
3430
+ response = self.call_deepseek(
3431
+ 'You are an academic librarian. Return only a JSON array with three new concise English search queries, each under five terms.',
3432
+ f"Topic: {topic}\nKeywords: {keywords}\nUsed: {sorted(all_used_terms)}",
3433
+ temp=0.4,
3434
+ model_role='fast',
3435
+ logical_node='search_jit',
3436
+ )
3437
+ parsed = self._extract_json_from_text(response)
3438
+ terms = parsed if isinstance(parsed, list) else []
 
 
 
 
 
 
 
 
 
3439
 
3440
  normalized_terms = []
3441
  for value in terms:
 
5088
  return True
5089
  return False
5090
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5091
  @classmethod
5092
  def _is_study_owned_qualitative_claim(cls, sentence, section_name=""):
5093
  """Conservatively identify only this manuscript's empirical/procedural claims.
 
5102
  return False
5103
  if re.match(r'^["“‘\']', stripped) or re.search(r"\bQUOTE-\d+\b", value, flags=re.I):
5104
  return False
 
 
 
 
 
5105
  if cls._has_external_literature_marker(value):
5106
  return False
5107
  if re.search(r"(?i)\b(?:prior|previous|earlier|existing)\s+(?:research|stud(?:y|ies)|literature)\b", value):
 
5155
  "ethics": [str(x or "").strip().casefold() for x in (facts.get("ethics_procedures") or []) if str(x or "").strip()],
5156
  "reflexivity": [str(x or "").strip().casefold() for x in (facts.get("reflexivity_procedures") or []) if str(x or "").strip()],
5157
  }
 
 
 
5158
  procedure_patterns = {
5159
  "trustworthiness": r"\bmember\s+check(?:ing|s)?\b|\bpeer\s+debrief(?:ing|ed)?\b|\baudit\s+trail\b|\bexternal\s+audit\b",
5160
  "ethics": r"\binformed\s+consent\b|\bethics?\s+approval\b|\binstitutional\s+review\s+board\b|\bIRB\b|\bapproval\s+(?:number|code)\b",
 
5191
  if not core.strip() or not cls._is_study_owned_qualitative_claim(core, section_name):
5192
  continue
5193
 
 
 
 
 
 
 
 
 
 
 
 
 
5194
  # Check every explicit procedure mention; do not stop after the first.
5195
  for category, pattern in procedure_patterns.items():
5196
  for match in re.finditer(pattern, core, flags=re.I):
 
5253
  "ethics": [str(x or "").strip() for x in (facts.get("ethics_procedures") or []) if str(x or "").strip()],
5254
  "reflexivity": [str(x or "").strip() for x in (facts.get("reflexivity_procedures") or []) if str(x or "").strip()],
5255
  }
 
 
 
5256
  allowed_by_actor = {}
5257
  canonical_relations = []
5258
  canonical_by_actor = {}
 
5287
  lower = core.casefold()
5288
  if not core.strip() or not cls._is_study_owned_qualitative_claim(core, section_name):
5289
  continue
 
 
 
 
 
 
 
 
 
 
5290
  removed = False
5291
  for category, pattern in procedure_patterns.items():
5292
  procedure_match = re.search(pattern, core, flags=re.I)
 
6070
  )
6071
  return revised
6072
 
6073
+ @staticmethod
6074
+ def _high_confidence_noncanonical_citation_spans(text):
6075
+ """Return high-confidence direct author-year citation forms outside canonical parentheses.
6076
+
6077
+ These forms are never rebound heuristically. They are surfaced as typed
6078
+ section violations so the owning writer can retry with its frozen CITE
6079
+ capability or narrow the unsupported claim. Patterns intentionally avoid
6080
+ bare years and tokens containing digits to reduce false positives for
6081
+ model/version/statistical expressions.
6082
+ """
6083
+ value = str(text or '')
6084
+ surname = r"[A-ZÀ-ÖØ-Þ][A-Za-zÀ-ÖØ-öø-ÿ'’.-]{1,39}"
6085
+ conjunction = r"(?:&|and|AND)"
6086
+ et_al = r"(?:et|Et|ET)\s+(?:al|Al|AL)\."
6087
+ author_group = rf"(?:{surname}(?:\s+{conjunction}\s+{surname})?|{surname}\s+{et_al})"
6088
+ year = r"(?:19|20)\d{2}[a-z]?"
6089
+ patterns = (
6090
+ re.compile(rf"\[(?P<cite>{author_group}\s*,\s*{year})\]"),
6091
+ re.compile(rf"(?<![\w\[])\b(?P<cite>{author_group})\s*\(\s*(?P<year>{year})\s*\)"),
6092
+ )
6093
+ spans = []
6094
+ for pattern in patterns:
6095
+ for match in pattern.finditer(value):
6096
+ candidate = match.group(0).strip()
6097
+ # Canonical SmartPaper markers are handled separately and always
6098
+ # contain the author and year inside the same parentheses.
6099
+ if candidate.startswith('(') and candidate.endswith(')'):
6100
+ continue
6101
+ spans.append((match.start(), match.end(), candidate))
6102
+ # Stable de-duplication for overlapping patterns.
6103
+ unique = []
6104
+ seen = set()
6105
+ for row in sorted(spans, key=lambda item: (item[0], item[1], item[2])):
6106
+ key = (row[0], row[1], row[2])
6107
+ if key not in seen:
6108
+ seen.add(key)
6109
+ unique.append(row)
6110
+ return unique
6111
+
6112
+ def _normalize_pre_resolved_citation_authority(self, text, *, allowed_tags=None, section_name=''):
6113
+ """Normalize only exact, authoritative pre-resolved markers back to CITE tags.
6114
+
6115
+ The Section Writer is supposed to emit frozen ``[CITE-XX]`` capabilities.
6116
+ A model-emitted reader-facing author-year marker therefore has no provenance
6117
+ until it is matched exactly against the *compiled* citation authority.
6118
+
6119
+ Safe normalization requires all of the following:
6120
+ - exact marker equality after ``compile_citation_identities()``;
6121
+ - authoritative reference metadata;
6122
+ - one distinct source identity for the visible marker;
6123
+ - at least one matching tag inside this manuscript unit's whitelist.
6124
+
6125
+ Anything else becomes a typed violation. No fuzzy author/year matching,
6126
+ metadata synthesis, or silent citation deletion is permitted here.
6127
+ """
6128
+ value = str(text or '')
6129
+ allowed = None if allowed_tags is None else {
6130
+ str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
6131
+ }
6132
+ self.compile_citation_identities()
6133
+
6134
+ authoritative = {}
6135
+ visible_any = {}
6136
+ for tag, metadata in (self.citation_map or {}).items():
6137
+ meta = metadata or {}
6138
+ marker = str(meta.get('in_text') or '').strip()
6139
+ if not marker:
6140
+ continue
6141
+ visible_any.setdefault(marker, []).append(str(tag))
6142
+ if str(meta.get('ref') or '').strip():
6143
+ authoritative.setdefault(marker, []).append(str(tag))
6144
+
6145
+ violations = []
6146
+ replacements = []
6147
+ for marker in list(dict.fromkeys(self._resolved_parenthetical_citations(value))):
6148
+ tags = list(authoritative.get(marker) or [])
6149
+ if not tags:
6150
+ visible_tags = sorted(visible_any.get(marker) or [], key=self._citation_tag_number)
6151
+ violations.append({
6152
+ 'type': 'citation_resolved_marker_without_authority',
6153
+ 'citation': marker,
6154
+ 'tag': visible_tags[0] if len(visible_tags) == 1 else '',
6155
+ 'sentence': marker,
6156
+ 'reason': 'A reader-facing author-year citation was emitted without authoritative reference metadata.',
6157
+ 'repair_instruction': (
6158
+ 'Do not emit author-year citations directly. Use only an authorized CITE tag from this manuscript unit '
6159
+ 'when its evidence directly supports the claim, or narrow/rewrite the claim so no unsupported citation is needed.'
6160
+ ),
6161
+ })
6162
+ continue
6163
+
6164
+ distinct_sources = {
6165
+ self._citation_source_identity(self.citation_map.get(tag) or {})
6166
+ for tag in tags
6167
+ }
6168
+ if len(distinct_sources) != 1:
6169
+ violations.append({
6170
+ 'type': 'citation_resolved_marker_ambiguous',
6171
+ 'citation': marker,
6172
+ 'sentence': marker,
6173
+ 'reason': 'The reader-facing author-year marker maps to multiple distinct authoritative sources.',
6174
+ 'repair_instruction': (
6175
+ 'Do not guess which source was intended. Rewrite this claim using exactly one authorized CITE tag '
6176
+ 'from the frozen evidence plan, or remove/narrow the unsupported citation-dependent claim.'
6177
+ ),
6178
+ })
6179
+ continue
6180
+
6181
+ eligible = tags if allowed is None else [tag for tag in tags if tag in allowed]
6182
+ if not eligible:
6183
+ violations.append({
6184
+ 'type': 'citation_resolved_marker_outside_frozen_section_whitelist',
6185
+ 'citation': marker,
6186
+ 'tag': sorted(tags, key=self._citation_tag_number)[0] if tags else '',
6187
+ 'sentence': marker,
6188
+ 'reason': 'The citation is authoritative globally but is not authorized for this manuscript unit.',
6189
+ 'repair_instruction': (
6190
+ 'Use only citation tags frozen for this manuscript unit. If no eligible source directly supports the claim, '
6191
+ 'narrow or rewrite the claim without external citation support.'
6192
+ ),
6193
+ })
6194
+ continue
6195
+
6196
+ selected = sorted(eligible, key=self._citation_tag_number)[0]
6197
+ replacements.append((marker, selected))
6198
+ self._append_citation_audit(
6199
+ section_name, 'normalize_exact_pre_resolved_marker_to_authoritative_tag',
6200
+ marker, selected, 'Exact compiled marker + authoritative reference + current-unit whitelist.'
6201
+ )
6202
+
6203
+ # Reject other high-confidence author-year forms rather than attempting a
6204
+ # lossy conversion to canonical identity. The bounded Section retry owns
6205
+ # prose repair and claim narrowing.
6206
+ for _start, _end, citation in self._high_confidence_noncanonical_citation_spans(value):
6207
+ violations.append({
6208
+ 'type': 'citation_noncanonical_resolved_marker',
6209
+ 'citation': citation,
6210
+ 'sentence': citation,
6211
+ 'reason': 'A direct author-year citation form bypasses the frozen CITE-tag authority path.',
6212
+ 'repair_instruction': (
6213
+ 'Do not emit narrative or bracketed author-year citations directly. Use one authorized CITE tag '
6214
+ 'from this manuscript unit when directly supported, or rewrite the claim without that citation.'
6215
+ ),
6216
+ })
6217
+
6218
+ for marker, tag in replacements:
6219
+ value = value.replace(marker, tag)
6220
+ return value, violations
6221
+
6222
+ def _quarantined_unit_citation_violations(self, text):
6223
+ """Return typed citations found in a manuscript unit that forbids literature.
6224
+
6225
+ Conclusion/Appendix-style units are explicitly writer-quarantined from
6226
+ external literature. P3-A4 therefore treats any high-confidence raw or
6227
+ reader-facing citation form as a retry-owned scope violation instead of
6228
+ silently deleting the marker and leaving the surrounding claim intact.
6229
+ Bare years and ordinary numeric/model-version expressions are excluded by
6230
+ the same conservative scanners used by the normal citation path.
6231
+ """
6232
+ value = str(text or '')
6233
+ found = []
6234
+ seen = set()
6235
+
6236
+ for raw in re.findall(r'\[?CITE-\d+\]?', value, flags=re.I):
6237
+ token = raw.upper()
6238
+ key = ('raw', token)
6239
+ if key not in seen:
6240
+ seen.add(key)
6241
+ found.append(token)
6242
+
6243
+ for marker in self._resolved_parenthetical_citations(value):
6244
+ key = ('resolved', marker)
6245
+ if key not in seen:
6246
+ seen.add(key)
6247
+ found.append(marker)
6248
+
6249
+ for _start, _end, citation in self._high_confidence_noncanonical_citation_spans(value):
6250
+ key = ('noncanonical', citation)
6251
+ if key not in seen:
6252
+ seen.add(key)
6253
+ found.append(citation)
6254
+
6255
+ return [{
6256
+ 'type': 'citation_forbidden_in_quarantined_unit',
6257
+ 'citation': citation,
6258
+ 'sentence': citation,
6259
+ 'reason': 'This manuscript unit forbids external literature citations, but a citation form was emitted.',
6260
+ 'repair_instruction': (
6261
+ 'Rewrite this unit without external literature citations. Preserve only documented study evidence, '
6262
+ 'study-owned interpretation, and the section role; do not replace the citation with another source.'
6263
+ ),
6264
+ } for citation in found]
6265
+
6266
  def audit_and_resolve_citations(
6267
  self, text, section_name='', section_id='', quarantine=False, allowed_tags=None
6268
  ):
 
6272
  )
6273
  value = self.strip_ineligible_current_study_citations(str(text or ''), section_name)
6274
  if quarantine:
6275
+ quarantine_rows = self._quarantined_unit_citation_violations(value)
6276
+ if ownership_rows or quarantine_rows:
6277
+ # Do not silently delete a citation-dependent claim. The owning
6278
+ # Section retry/adaptive-repair path receives a typed violation
6279
+ # and must rewrite the unit without external literature.
6280
+ self._last_citation_semantic_violations = (
6281
+ list(ownership_rows) + list(quarantine_rows)
6282
+ )
6283
+ for row in quarantine_rows:
6284
+ self._append_citation_audit(
6285
+ section_name, 'reject_citation_in_quarantined_unit',
6286
+ str(row.get('citation') or ''), '', str(row.get('reason') or '')
6287
+ )
6288
+ return value
6289
+ return value
6290
+
6291
+ # P3-A4: collect deterministic citation-authority violations separately.
6292
+ # semantic_audit_claim_specific_citations() owns its own last-violation
6293
+ # buffer, so these rows are merged back only after that audit completes.
6294
+ authority_rows = []
6295
  unknown = sorted({f"[{tag.upper()}]" for tag in re.findall(r'CITE-\d+', value, re.I)} - set(self.citation_map))
6296
  if unknown:
6297
  for tag in unknown:
6298
+ authority_rows.append({
6299
+ 'type': 'citation_unknown_tag',
6300
+ 'tag': tag,
6301
+ 'sentence': tag,
6302
+ 'reason': 'The manuscript unit emitted a citation capability that does not exist in authoritative citation_map.',
6303
+ 'repair_instruction': (
6304
+ 'Use only citation tags frozen for this manuscript unit, or narrow/rewrite the claim so no unsupported citation is needed.'
6305
+ ),
6306
+ })
6307
  value = re.sub(rf'\s*\[?{re.escape(tag.strip("[]"))}\]?\s*', ' ', value, flags=re.I)
6308
+ self._append_citation_audit(section_name, 'reject_unknown_tags_for_section_retry', text, value, ','.join(unknown))
6309
+
6310
  allowed = None if allowed_tags is None else {
6311
  str(tag or '').strip() for tag in allowed_tags if str(tag or '').strip()
6312
  }
 
6326
  self._append_citation_audit(
6327
  section_name, 'reject_outside_frozen_section_whitelist', text, value, tag
6328
  )
6329
+
6330
+ # P3-A4 closes the resolved-marker bypass before section sealing. Exact,
6331
+ # unambiguous, current-unit-authorized markers are normalized back to raw
6332
+ # capabilities so the existing claim-specific semantic audit still owns
6333
+ # claim/evidence adjudication. All other forms become typed violations.
6334
+ value, resolved_authority_rows = self._normalize_pre_resolved_citation_authority(
6335
+ value, allowed_tags=allowed, section_name=section_name
6336
+ )
6337
+ authority_rows.extend(resolved_authority_rows)
6338
+
6339
  has_citations = bool(re.search(r'CITE-\d+', value, re.I))
6340
  violations = self.find_multi_citation_violations(value)
6341
  if has_citations and self.citation_semantic_audit.get('enabled', True):
 
6366
  value = self._local_citation_recovery(value, section_name, retain_lexical=False)
6367
  elif violations:
6368
  value = self._local_citation_recovery(value, section_name)
6369
+
6370
+ pre_semantic_rows = list(ownership_rows) + list(outside_rows) + list(authority_rows)
6371
+ if pre_semantic_rows:
6372
  self._last_citation_semantic_violations = (
6373
+ pre_semantic_rows + list(self._last_citation_semantic_violations or [])
 
 
6374
  )
6375
+
6376
+ # P3-A4 reliability rule: once deterministic identity/scope authority has
6377
+ # already rejected this attempt, return it to the owning bounded Section
6378
+ # retry instead of letting the legacy multi-source hard exception preempt
6379
+ # typed recovery. The rejected bytes can never be sealed because runner
6380
+ # treats these rows as a hard pre-seal invariant.
6381
+ if outside_rows or authority_rows:
6382
+ return value
6383
+
6384
  if self.find_multi_citation_violations(value):
6385
  value = self._local_citation_recovery(value, section_name)
6386
  if self.find_multi_citation_violations(value):