S0-R1-C5.5-R6-R10 Quantitative Method Citation Retry Frame

#98
core/generation_runner.py CHANGED
@@ -1070,6 +1070,47 @@ class GenerationRunner:
1070
  return lineage
1071
  return kind
1072
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1073
  @classmethod
1074
  def _section_retry_decision(cls, rows, lineage_attempts, *, hard_cap_reached: bool = False):
1075
  """Return (may_retry, exhausted_lineages, new_lineages).
@@ -2995,7 +3036,12 @@ class GenerationRunner:
2995
  self._build_qualitative_quote_invocation_plan(adaptive_structure, data_metadata)
2996
  if mode == "asq" else {}
2997
  )
2998
- jit_texts = agent.prefetch_jit_literature(topic, keywords, adaptive_structure)
 
 
 
 
 
2999
  if jit_texts:
3000
  rag_engine._initialize_chunks([jit_texts])
3001
  self._progress(15, "citation_planning")
@@ -3690,9 +3736,11 @@ class GenerationRunner:
3690
  "\nEVIDENCE-LIMITED CLAIM-DENSITY CONTRACT: Direct high-relevance literature coverage is limited. Treat assigned claim-evidence items as the ceiling for source-dependent empirical, definitional, prevalence, and field-gap claims in this unit. "
3691
  "Do not invent additional literature claims merely to make the section sound comprehensive. Prefer concise study positioning, cautious conceptual reasoning, and clearly qualified interpretations when no directly validated source is assigned."
3692
  )
3693
- memory_context = agent._build_memory_context(sec_title, 2500)
 
 
3694
  memory_block = f"\n<GLOBAL_MEMORY>\n{memory_context}\n</GLOBAL_MEMORY>\n" if memory_context else ""
3695
- snippets = rag_engine.get_relevant_context(display_name) if rag_engine is not None else ""
3696
  if snippets:
3697
  memory_block += f"\n<TARGETED_RAG>\n{snippets}\n</TARGETED_RAG>\n"
3698
  heading_directive = "Do not output the section heading; it will be inserted by the system."
@@ -3803,7 +3851,10 @@ class GenerationRunner:
3803
  "When no supported external claim is assigned, prefer concise study positioning, transparent limitations, and cautious conceptual reasoning without invented citation support."
3804
  )
3805
  else:
3806
- writer_synthesis_context = synthesis_result
 
 
 
3807
  user_prompt = f"""Write the assigned English manuscript unit.
3808
  Section: {sec_title}
3809
  Subsection: {subsection or 'None'}
@@ -3861,6 +3912,14 @@ Output only final academic prose. Do not report the word count."""
3861
  # Four is a hard safety cap, not a blanket four-attempt entitlement.
3862
  max_section_attempts = 1 if deterministic_appendix else 4
3863
  lineage_attempts = {}
 
 
 
 
 
 
 
 
3864
  frozen_citation_state = self._snapshot_section_citation_state(
3865
  agent, citation_plan, display_name
3866
  )
@@ -3897,6 +3956,13 @@ Output only final academic prose. Do not report the word count."""
3897
  f"<CLAIM_EVIDENCE_PLAN>\n{retry_claim_plan}\n</CLAIM_EVIDENCE_PLAN>",
3898
  candidate_user_prompt, flags=re.S,
3899
  )
 
 
 
 
 
 
 
3900
  if attempt_feedback:
3901
  excluded_text = ", ".join(sorted(retry_excluded_tags, key=agent._citation_tag_number)) or "None"
3902
  candidate_user_prompt += f"""
@@ -3995,20 +4061,49 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3995
  preview_blocking, preview_advisory = self._partition_section_violations(
3996
  phase1_validation_rows, section_name=display_name, mode=mode, exhausted=False
3997
  )
 
 
 
 
 
 
 
 
3998
  may_retry, exhausted_lineages, fresh_lineages = self._section_retry_decision(
3999
  preview_blocking, lineage_attempts, hard_cap_reached=hard_cap_reached
4000
  )
 
 
 
 
4001
  if may_retry:
4002
  retry_rows = [
4003
  row for row in preview_blocking
4004
- if self._section_violation_lineage(row) in fresh_lineages
4005
  ]
4006
  for lineage in fresh_lineages:
4007
  lineage_attempts[lineage] = int(lineage_attempts.get(lineage, 0)) + 1
 
 
 
 
 
 
 
 
4008
  attempt_feedback = self._section_retry_feedback(retry_rows)
 
 
 
 
 
 
 
 
4009
  logging.warning(
4010
- "Unified Phase-1 Section lineage retry | unit=%s attempt=%s lineages=%s exhausted_siblings=%s count=%s types=%s",
4011
- display_name, section_attempt, sorted(fresh_lineages), sorted(exhausted_lineages), len(retry_rows),
 
4012
  sorted({str(row.get("type") or "") for row in retry_rows if isinstance(row, dict)}),
4013
  )
4014
  self._restore_section_citation_state(
 
1070
  return lineage
1071
  return kind
1072
 
1073
+ @staticmethod
1074
+ def _strip_unscoped_citation_identities(value):
1075
+ """Remove raw citation capability tokens from non-authoritative context."""
1076
+ return re.sub(r"\[CITE-\d+\]", "", str(value or ""), flags=re.I)
1077
+
1078
+ @staticmethod
1079
+ def _section_citation_capability_context(agent, planned_items, max_chars=12000):
1080
+ """Expose only citation identities already frozen for this manuscript unit.
1081
+
1082
+ Retrieval visibility is not citation authority. R6-R10 therefore builds
1083
+ writer-facing targeted context from the unit's frozen claim-evidence plan
1084
+ instead of the global RAG pool, which may contain METHOD_AUTHORITY tags
1085
+ owned by another unit.
1086
+ """
1087
+ rows = []
1088
+ seen = set()
1089
+ for item in list(planned_items or []):
1090
+ if not isinstance(item, dict):
1091
+ continue
1092
+ tag = str(item.get("tag") or "").strip()
1093
+ if not tag or tag in seen:
1094
+ continue
1095
+ metadata = dict(getattr(agent, "citation_map", {}).get(tag) or {})
1096
+ evidence = str(item.get("evidence") or metadata.get("evidence") or "").strip()
1097
+ claim = str(item.get("claim") or "").strip()
1098
+ if not evidence:
1099
+ continue
1100
+ seen.add(tag)
1101
+ row = f"{tag} AUTHORIZED CLAIM: {claim}\nEVIDENCE: {evidence}" if claim else f"{tag} EVIDENCE: {evidence}"
1102
+ rows.append(row)
1103
+ value = "\n\n--- Authorized Section Evidence ---\n".join(rows)
1104
+ return value[:max(0, int(max_chars))]
1105
+
1106
+ @staticmethod
1107
+ def _section_regressed_lineages(current_lineages, cleared_lineages, regression_attempts):
1108
+ """Return previously-cleared lineages reintroduced by a later sibling repair."""
1109
+ current = {str(value) for value in (current_lineages or set()) if str(value)}
1110
+ cleared = {str(value) for value in (cleared_lineages or set()) if str(value)}
1111
+ attempts = dict(regression_attempts or {})
1112
+ return {name for name in current & cleared if int(attempts.get(name, 0)) < 1}
1113
+
1114
  @classmethod
1115
  def _section_retry_decision(cls, rows, lineage_attempts, *, hard_cap_reached: bool = False):
1116
  """Return (may_retry, exhausted_lineages, new_lineages).
 
3036
  self._build_qualitative_quote_invocation_plan(adaptive_structure, data_metadata)
3037
  if mode == "asq" else {}
3038
  )
3039
+ jit_texts = agent.prefetch_jit_literature(
3040
+ topic, keywords, adaptive_structure,
3041
+ quantitative_evidence_ledger=(
3042
+ data_metadata.get("quantitative_evidence_ledger") if mode == "amj" else None
3043
+ ),
3044
+ )
3045
  if jit_texts:
3046
  rag_engine._initialize_chunks([jit_texts])
3047
  self._progress(15, "citation_planning")
 
3736
  "\nEVIDENCE-LIMITED CLAIM-DENSITY CONTRACT: Direct high-relevance literature coverage is limited. Treat assigned claim-evidence items as the ceiling for source-dependent empirical, definitional, prevalence, and field-gap claims in this unit. "
3737
  "Do not invent additional literature claims merely to make the section sound comprehensive. Prefer concise study positioning, cautious conceptual reasoning, and clearly qualified interpretations when no directly validated source is assigned."
3738
  )
3739
+ memory_context = self._strip_unscoped_citation_identities(
3740
+ agent._build_memory_context(sec_title, 2500)
3741
+ )
3742
  memory_block = f"\n<GLOBAL_MEMORY>\n{memory_context}\n</GLOBAL_MEMORY>\n" if memory_context else ""
3743
+ snippets = self._section_citation_capability_context(agent, planned_items)
3744
  if snippets:
3745
  memory_block += f"\n<TARGETED_RAG>\n{snippets}\n</TARGETED_RAG>\n"
3746
  heading_directive = "Do not output the section heading; it will be inserted by the system."
 
3851
  "When no supported external claim is assigned, prefer concise study positioning, transparent limitations, and cautious conceptual reasoning without invented citation support."
3852
  )
3853
  else:
3854
+ # Literature synthesis is planning context, not a citation capability.
3855
+ # Raw identities are stripped; only the frozen Section plan below may
3856
+ # expose a usable [CITE-XX] token to the writer.
3857
+ writer_synthesis_context = self._strip_unscoped_citation_identities(synthesis_result)
3858
  user_prompt = f"""Write the assigned English manuscript unit.
3859
  Section: {sec_title}
3860
  Subsection: {subsection or 'None'}
 
3912
  # Four is a hard safety cap, not a blanket four-attempt entitlement.
3913
  max_section_attempts = 1 if deterministic_appendix else 4
3914
  lineage_attempts = {}
3915
+ # R6-R10 monotonic retry state. A defect that was repaired and absent
3916
+ # on an intervening attempt may receive one regression-correction turn
3917
+ # if a later sibling repair reintroduces it. The Section hard cap is
3918
+ # unchanged, so this cannot create an unbounded retry loop.
3919
+ seen_lineages = set()
3920
+ cleared_lineages = set()
3921
+ regression_attempts = {}
3922
+ historical_retry_rows = {}
3923
  frozen_citation_state = self._snapshot_section_citation_state(
3924
  agent, citation_plan, display_name
3925
  )
 
3956
  f"<CLAIM_EVIDENCE_PLAN>\n{retry_claim_plan}\n</CLAIM_EVIDENCE_PLAN>",
3957
  candidate_user_prompt, flags=re.S,
3958
  )
3959
+ retry_capability_context = self._section_citation_capability_context(agent, planned_items)
3960
+ candidate_user_prompt = re.sub(
3961
+ r"\n<TARGETED_RAG>.*?</TARGETED_RAG>\n",
3962
+ (f"\n<TARGETED_RAG>\n{retry_capability_context}\n</TARGETED_RAG>\n"
3963
+ if retry_capability_context else "\n"),
3964
+ candidate_user_prompt, flags=re.S,
3965
+ )
3966
  if attempt_feedback:
3967
  excluded_text = ", ".join(sorted(retry_excluded_tags, key=agent._citation_tag_number)) or "None"
3968
  candidate_user_prompt += f"""
 
4061
  preview_blocking, preview_advisory = self._partition_section_violations(
4062
  phase1_validation_rows, section_name=display_name, mode=mode, exhausted=False
4063
  )
4064
+ current_lineages = {
4065
+ self._section_violation_lineage(row) for row in preview_blocking
4066
+ }
4067
+ cleared_lineages.update(seen_lineages - current_lineages)
4068
+ regressed_lineages = self._section_regressed_lineages(
4069
+ current_lineages, cleared_lineages, regression_attempts
4070
+ )
4071
+ seen_lineages.update(current_lineages)
4072
  may_retry, exhausted_lineages, fresh_lineages = self._section_retry_decision(
4073
  preview_blocking, lineage_attempts, hard_cap_reached=hard_cap_reached
4074
  )
4075
+ retry_lineages = set(fresh_lineages)
4076
+ if regressed_lineages and not hard_cap_reached:
4077
+ retry_lineages.update(regressed_lineages)
4078
+ may_retry = True
4079
  if may_retry:
4080
  retry_rows = [
4081
  row for row in preview_blocking
4082
+ if self._section_violation_lineage(row) in retry_lineages
4083
  ]
4084
  for lineage in fresh_lineages:
4085
  lineage_attempts[lineage] = int(lineage_attempts.get(lineage, 0)) + 1
4086
+ for lineage in regressed_lineages:
4087
+ regression_attempts[lineage] = int(regression_attempts.get(lineage, 0)) + 1
4088
+ for row in preview_blocking:
4089
+ historical_retry_rows[self._section_violation_lineage(row)] = dict(row)
4090
+ # Every later repair is constrained by all earlier resolved
4091
+ # contracts, not only the newly visible lineage. This makes
4092
+ # A+B -> C -> A+B repair interference explicitly non-monotonic
4093
+ # and gives it one bounded correction turn.
4094
  attempt_feedback = self._section_retry_feedback(retry_rows)
4095
+ historical_feedback = self._section_retry_feedback(
4096
+ list(historical_retry_rows.values())
4097
+ )
4098
+ if historical_feedback:
4099
+ attempt_feedback += (
4100
+ "\nNONREGRESSION CONSTRAINTS — previously repaired lineages must remain resolved:\n"
4101
+ + historical_feedback
4102
+ )
4103
  logging.warning(
4104
+ "Unified Phase-1 Section lineage retry | unit=%s attempt=%s lineages=%s exhausted_siblings=%s regressed_lineages=%s count=%s types=%s",
4105
+ display_name, section_attempt, sorted(retry_lineages), sorted(exhausted_lineages),
4106
+ sorted(regressed_lineages), len(retry_rows),
4107
  sorted({str(row.get("type") or "") for row in retry_rows if isinstance(row, dict)}),
4108
  )
4109
  self._restore_section_citation_state(
core/pipeline_agent.py CHANGED
@@ -2311,8 +2311,27 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
2311
  return violations
2312
 
2313
  @staticmethod
2314
- def _method_authority_queries(structure):
2315
- """Derive bounded methodology queries from design language, never topic keywords."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2316
  text_parts = []
2317
  for sec in structure or []:
2318
  if not isinstance(sec, dict):
@@ -2350,10 +2369,12 @@ If the immutable hypothesis contract is non-empty, preserve its H-identifiers an
2350
  queries.append('empirical research design methodology')
2351
  return list(dict.fromkeys(queries))[:3]
2352
 
2353
- def prefetch_jit_literature(self, topic, keywords, structure):
2354
  """Retrieve a dedicated methodology-authority lane without an LLM query call."""
2355
  jit_rag_texts = []
2356
- queries_to_fetch = self._method_authority_queries(structure)
 
 
2357
  if not queries_to_fetch:
2358
  return ""
2359
  logging.info("METHOD_AUTHORITY retrieval | queries=%s", queries_to_fetch)
 
2311
  return violations
2312
 
2313
  @staticmethod
2314
+ def _method_authority_queries(structure, quantitative_evidence_ledger=None):
2315
+ """Derive bounded methodology queries from the *owned* evidence contract.
2316
+
2317
+ R6-R10 prevents generic Methodology headings from retrieving methods the
2318
+ study did not perform (for example SEM for a VIF/bootstrap design).
2319
+ """
2320
+ ledger = dict(quantitative_evidence_ledger or {})
2321
+ if bool(ledger.get("strict")):
2322
+ allowed = {str(value or "").strip().casefold() for value in (ledger.get("allowed_methods") or [])}
2323
+ queries = []
2324
+ if allowed & {"reliability", "composite reliability", "average variance extracted", "fornell-larcker"}:
2325
+ queries.append("measurement reliability validity methodology")
2326
+ if "vif" in allowed:
2327
+ queries.append("variance inflation factor collinearity methodology")
2328
+ if "bootstrap" in allowed:
2329
+ queries.append("bootstrap inference methodology")
2330
+ if "spearman" in allowed:
2331
+ queries.append("Spearman rank correlation methodology")
2332
+ if not queries:
2333
+ queries.append("empirical association analysis methodology")
2334
+ return list(dict.fromkeys(queries))[:3]
2335
  text_parts = []
2336
  for sec in structure or []:
2337
  if not isinstance(sec, dict):
 
2369
  queries.append('empirical research design methodology')
2370
  return list(dict.fromkeys(queries))[:3]
2371
 
2372
+ def prefetch_jit_literature(self, topic, keywords, structure, quantitative_evidence_ledger=None):
2373
  """Retrieve a dedicated methodology-authority lane without an LLM query call."""
2374
  jit_rag_texts = []
2375
+ queries_to_fetch = self._method_authority_queries(
2376
+ structure, quantitative_evidence_ledger=quantitative_evidence_ledger
2377
+ )
2378
  if not queries_to_fetch:
2379
  return ""
2380
  logging.info("METHOD_AUTHORITY retrieval | queries=%s", queries_to_fetch)
core/quantitative_engine.py CHANGED
@@ -861,6 +861,98 @@ Keywords: {keywords}"""
861
  ] if strict else [],
862
  }
863
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
864
  @staticmethod
865
  def _semantic_frame_hash(payload):
866
  canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
@@ -1345,7 +1437,12 @@ Keywords: {keywords}"""
1345
 
1346
  for _start, _end, sentence in cls._sentence_spans(value):
1347
  ownership = cls._quantitative_sentence_ownership(sentence, section_name, known_constructs)
1348
- if ownership not in {"study", "result"}:
 
 
 
 
 
1349
  continue
1350
  if sample_size:
1351
  for match in re.finditer(r"(?i)(\b(?:sample(?:\s+size)?(?:\s+of)?|N)\s*(?:=|:)?\s*)(\d{2,6})\b", sentence):
@@ -1392,10 +1489,16 @@ Keywords: {keywords}"""
1392
  })
1393
  if (any(c in sentence.casefold() for c in construct_folded) if construct_folded else False) and re.search(r"(?i)\bp\s*(?:=|<|>|≤|≥)\s*(?:0?\.\d+|\d+(?:\.\d+)?)", sentence):
1394
  violations.append({"type":"unfrozen_p_value","preview":sentence.strip()[:180]})
1395
- if ownership == "study":
1396
- bad = next((term for term in forbidden if re.search(rf"(?i)(?<![A-Za-z]){re.escape(term)}(?![A-Za-z])", sentence)), "")
1397
- if bad:
1398
- violations.append({"type":"unsupported_quantitative_method","method":bad,"preview":sentence.strip()[:180]})
 
 
 
 
 
 
1399
 
1400
  vif_expected = results.get("predictor_mediator_vif") if strict else uploaded.get("VIF")
1401
  boot_expected = results.get("bootstrap_iterations") if strict else uploaded.get("bootstrap_iterations")
@@ -1474,7 +1577,12 @@ Keywords: {keywords}"""
1474
  replacements = []
1475
  for start, end, sentence in cls._sentence_spans(value):
1476
  ownership = cls._quantitative_sentence_ownership(sentence, section_name, known_constructs)
1477
- if ownership not in {"study", "result"}:
 
 
 
 
 
1478
  continue
1479
  revised = sentence
1480
 
@@ -1533,10 +1641,16 @@ Keywords: {keywords}"""
1533
  revised, p_count = re.subn(r"(?i)\s*[,;]?\s*p\s*(?:=|<|>|≤|≥)\s*(?:0?\.\d+|\d+(?:\.\d+)?)", "", revised)
1534
  if p_count:
1535
  diagnostics.append({"type":"unfrozen_p_value_removed","count":p_count})
1536
- if ownership == "study":
1537
- bad = next((term for term in forbidden if re.search(rf"(?i)(?<![A-Za-z]){re.escape(term)}(?![A-Za-z])", revised)), "")
1538
- if bad:
1539
- diagnostics.append({"type":"unsupported_quantitative_method_removed","method":bad,"preview":sentence.strip()[:180]})
 
 
 
 
 
 
1540
  revised = ""
1541
 
1542
  def replace_local_metric(pattern, expected, *, dtype="float", label="metric"):
 
861
  ] if strict else [],
862
  }
863
 
864
+ @classmethod
865
+ def classify_quantitative_method_threat_claim(cls, sentence, term):
866
+ """Classify a forbidden quantitative term by *claim role*, not token presence.
867
+
868
+ R6-R10 separates an actually performed/claimed unowned analysis from a
869
+ limitation, explicit non-use statement, threat discussion, or incidental
870
+ methodological mention. Only ``PERFORMED_UNOWNED`` is blocking.
871
+ """
872
+ text = re.sub(r"\s+", " ", str(sentence or "")).strip()
873
+ folded = text.casefold()
874
+ label = re.sub(r"\s+", " ", str(term or "")).strip()
875
+ term_folded = label.casefold()
876
+ if not text or not term_folded:
877
+ return {"term": label, "category": "UNKNOWN", "state": "ABSENT"}
878
+ match = re.search(rf"(?i)(?<![A-Za-z]){re.escape(label)}(?![A-Za-z])", text)
879
+ if not match:
880
+ return {"term": label, "category": "UNKNOWN", "state": "ABSENT"}
881
+
882
+ threat_terms = {"common method bias", "endogeneity"}
883
+ fit_terms = {"rmsea", "cfi", "tli", "srmr"}
884
+ category = (
885
+ "THREAT" if term_folded in threat_terms else
886
+ "FIT_DIAGNOSTIC" if term_folded in fit_terms else
887
+ "ANALYTIC_METHOD"
888
+ )
889
+ before = folded[max(0, match.start() - 100):match.start()]
890
+ after = folded[match.end():min(len(folded), match.end() + 120)]
891
+ local = folded[max(0, match.start() - 120):min(len(folded), match.end() + 140)]
892
+
893
+ scalar_negation = bool(re.search(
894
+ r"\bnot\s+(?:only|merely|just|simply)\b", local
895
+ ))
896
+ explicit_nonuse = (not scalar_negation) and bool(
897
+ re.search(
898
+ r"\b(?:no|not|never|without|did\s+not|does\s+not|do\s+not|was\s+not|were\s+not|is\s+not|are\s+not|cannot|can\s+not)\b[^.;:]{0,55}$",
899
+ before,
900
+ )
901
+ or re.search(
902
+ r"^.{0,45}\b(?:was\s+not|were\s+not|is\s+not|are\s+not|did\s+not|does\s+not|do\s+not|cannot|can\s+not|not\s+(?:used|conducted|performed|estimated|tested|assessed|evaluated|reported|implemented|applied))\b",
903
+ after,
904
+ )
905
+ or re.search(
906
+ r"\b(?:does\s+not|do\s+not|did\s+not|cannot|can\s+not|is\s+not|are\s+not|was\s+not|were\s+not)\s+(?:address|assess|test|establish|identify|rule\s+out|correct|control|substitute|demonstrate|show|prove|support)\b",
907
+ local,
908
+ )
909
+ )
910
+ if explicit_nonuse:
911
+ return {"term": label, "category": category, "state": "NOT_PERFORMED_OR_LIMITATION"}
912
+
913
+ performed = bool(
914
+ re.search(
915
+ r"\b(?:we|the\s+study|this\s+study|the\s+analysis|our\s+analysis|the\s+model|the\s+authors?)\b[^.;:]{0,70}\b(?:use|uses|used|employ|employs|employed|conduct|conducts|conducted|perform|performs|performed|estimate|estimates|estimated|test|tests|tested|assess|assesses|assessed|evaluate|evaluates|evaluated|implement|implements|implemented|apply|applies|applied|ran|run|runs|fit|fits|fitted|model|models|modeled|modelled|control|controls|controlled|correct|corrects|corrected|address|addresses|addressed)\b[^.;:]{0,70}$",
916
+ before,
917
+ )
918
+ or re.search(
919
+ r"^.{0,65}\b(?:was|were|is|are)\s+(?:used|employed|conducted|performed|estimated|tested|assessed|evaluated|implemented|applied|fit|fitted|modeled|modelled|controlled|corrected|addressed|reported)\b",
920
+ after,
921
+ )
922
+ or re.search(
923
+ r"^.{0,65}\b(?:estimated|tested|assessed|evaluated|showed|indicated|yielded|produced|confirmed|supported)\b",
924
+ after,
925
+ )
926
+ )
927
+ if category == "FIT_DIAGNOSTIC" and re.search(
928
+ rf"(?i)(?:{re.escape(label)}\s*(?:=|:|was|were)\s*[-+]?\d)|(?:reported|fit\s+index|fit\s+indices)[^.;:]{{0,45}}{re.escape(label)}",
929
+ text,
930
+ ):
931
+ performed = True
932
+ if category == "THREAT":
933
+ # Threats may be discussed as limitations without implying a test.
934
+ if re.search(
935
+ rf"(?i)(?:assess|test|evaluate|examine|address|correct|control)(?:ed|ing|s)?[^.;:]{{0,45}}{re.escape(label)}|{re.escape(label)}[^.;:]{{0,45}}(?:was|were)\s+(?:assessed|tested|evaluated|examined|addressed|corrected|controlled)",
936
+ text,
937
+ ):
938
+ performed = True
939
+ elif not performed:
940
+ return {"term": label, "category": category, "state": "LIMITATION_OR_THREAT"}
941
+
942
+ return {
943
+ "term": label,
944
+ "category": category,
945
+ "state": "PERFORMED_UNOWNED" if performed else "MENTION_ONLY",
946
+ }
947
+
948
+ @classmethod
949
+ def _first_performed_unowned_quantitative_term(cls, sentence, forbidden_terms):
950
+ for term in forbidden_terms or []:
951
+ frame = cls.classify_quantitative_method_threat_claim(sentence, term)
952
+ if frame.get("state") == "PERFORMED_UNOWNED":
953
+ return frame
954
+ return None
955
+
956
  @staticmethod
957
  def _semantic_frame_hash(payload):
958
  canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
 
1437
 
1438
  for _start, _end, sentence in cls._sentence_spans(value):
1439
  ownership = cls._quantitative_sentence_ownership(sentence, section_name, known_constructs)
1440
+ method_scope_unknown = (
1441
+ strict and ownership == "unknown"
1442
+ and bool(re.search(r"(?i)method|measure", str(section_name or "")))
1443
+ and cls._first_performed_unowned_quantitative_term(sentence, forbidden) is not None
1444
+ )
1445
+ if ownership not in {"study", "result"} and not method_scope_unknown:
1446
  continue
1447
  if sample_size:
1448
  for match in re.finditer(r"(?i)(\b(?:sample(?:\s+size)?(?:\s+of)?|N)\s*(?:=|:)?\s*)(\d{2,6})\b", sentence):
 
1489
  })
1490
  if (any(c in sentence.casefold() for c in construct_folded) if construct_folded else False) and re.search(r"(?i)\bp\s*(?:=|<|>|≤|≥)\s*(?:0?\.\d+|\d+(?:\.\d+)?)", sentence):
1491
  violations.append({"type":"unfrozen_p_value","preview":sentence.strip()[:180]})
1492
+ if ownership == "study" or method_scope_unknown:
1493
+ method_frame = cls._first_performed_unowned_quantitative_term(sentence, forbidden)
1494
+ if method_frame:
1495
+ violations.append({
1496
+ "type":"unsupported_quantitative_method",
1497
+ "method":method_frame.get("term"),
1498
+ "claim_state":method_frame.get("state"),
1499
+ "method_category":method_frame.get("category"),
1500
+ "preview":sentence.strip()[:180],
1501
+ })
1502
 
1503
  vif_expected = results.get("predictor_mediator_vif") if strict else uploaded.get("VIF")
1504
  boot_expected = results.get("bootstrap_iterations") if strict else uploaded.get("bootstrap_iterations")
 
1577
  replacements = []
1578
  for start, end, sentence in cls._sentence_spans(value):
1579
  ownership = cls._quantitative_sentence_ownership(sentence, section_name, known_constructs)
1580
+ method_scope_unknown = (
1581
+ strict and ownership == "unknown"
1582
+ and bool(re.search(r"(?i)method|measure", str(section_name or "")))
1583
+ and cls._first_performed_unowned_quantitative_term(sentence, forbidden) is not None
1584
+ )
1585
+ if ownership not in {"study", "result"} and not method_scope_unknown:
1586
  continue
1587
  revised = sentence
1588
 
 
1641
  revised, p_count = re.subn(r"(?i)\s*[,;]?\s*p\s*(?:=|<|>|≤|≥)\s*(?:0?\.\d+|\d+(?:\.\d+)?)", "", revised)
1642
  if p_count:
1643
  diagnostics.append({"type":"unfrozen_p_value_removed","count":p_count})
1644
+ if ownership == "study" or method_scope_unknown:
1645
+ method_frame = cls._first_performed_unowned_quantitative_term(revised, forbidden)
1646
+ if method_frame:
1647
+ diagnostics.append({
1648
+ "type":"unsupported_quantitative_method_removed",
1649
+ "method":method_frame.get("term"),
1650
+ "claim_state":method_frame.get("state"),
1651
+ "method_category":method_frame.get("category"),
1652
+ "preview":sentence.strip()[:180],
1653
+ })
1654
  revised = ""
1655
 
1656
  def replace_local_metric(pattern, expected, *, dtype="float", label="metric"):