GuXiaopeng commited on
Commit
a2fb2ff
·
verified ·
1 Parent(s): 9a661e6

S0-R1-C5.5-R4 Cross-Mode Defect-Lineage Retry Ownership + Methodological Causal-Scope Closure

Browse files
core/generation_runner.py CHANGED
@@ -1035,6 +1035,48 @@ class GenerationRunner:
1035
  # new to the severity registry.
1036
  return "FATAL"
1037
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1038
  @classmethod
1039
  def _partition_section_violations(cls, rows, *, section_name: str = "", mode: str = "", exhausted: bool = False):
1040
  blocking, advisory = [], []
@@ -3621,7 +3663,10 @@ Output only final academic prose. Do not report the word count."""
3621
  raise RuntimeError(f"section_retry_exhausted_requires_new_lifecycle:{display_name}")
3622
 
3623
  deterministic_appendix = mode == "asq" and section_id == "appendix"
3624
- max_section_attempts = 1 if deterministic_appendix else 2
 
 
 
3625
  frozen_citation_state = self._snapshot_section_citation_state(
3626
  agent, citation_plan, display_name
3627
  )
@@ -3752,19 +3797,27 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3752
  )
3753
  if phase1_validation_rows:
3754
  retry_excluded_tags.update(self._citation_violation_tags(citation_semantic_rows))
3755
- exhausted = section_attempt >= max_section_attempts
 
 
 
 
 
 
 
 
 
3756
  blocking_rows, advisory_rows = self._partition_section_violations(
3757
  phase1_validation_rows, section_name=display_name, mode=mode, exhausted=exhausted
3758
  )
3759
- if blocking_rows and not exhausted:
 
 
3760
  attempt_feedback = self._section_retry_feedback(blocking_rows)
3761
  logging.warning(
3762
- "Unified Phase-1 Section retry | unit=%s attempt=%s count=%s types=%s",
3763
- display_name, section_attempt, len(blocking_rows),
3764
- sorted({
3765
- str(row.get("type") or "")
3766
- for row in blocking_rows if isinstance(row, dict)
3767
- }),
3768
  )
3769
  self._restore_section_citation_state(
3770
  agent, citation_plan, display_name, frozen_citation_state
@@ -3930,15 +3983,24 @@ The previous attempt for this same manuscript unit was rejected by validation. R
3930
  hypothesis_ledger=hypothesis_ledger,
3931
  )
3932
  if post_validation_rows:
3933
- exhausted = section_attempt >= max_section_attempts
 
 
 
 
 
 
 
3934
  blocking_rows, advisory_rows = self._partition_section_violations(
3935
  post_validation_rows, section_name=display_name, mode=mode, exhausted=exhausted
3936
  )
3937
- if blocking_rows and not exhausted:
 
 
3938
  attempt_feedback = self._section_retry_feedback(blocking_rows)
3939
  logging.warning(
3940
- "Post-normalization Section retry | unit=%s attempt=%s count=%s types=%s",
3941
- display_name, section_attempt, len(blocking_rows),
3942
  sorted({str(row.get("type") or "") for row in blocking_rows if isinstance(row, dict)}),
3943
  )
3944
  self._restore_section_citation_state(
 
1035
  # new to the severity registry.
1036
  return "FATAL"
1037
 
1038
+ @staticmethod
1039
+ def _section_violation_lineage(row) -> str:
1040
+ """Return a stable repair lineage for one section validation defect.
1041
+
1042
+ R4 assigns the bounded rewrite entitlement to the defect lineage rather
1043
+ than to the whole Section. Closely related validators share a lineage
1044
+ so spelling variants of the same integrity problem cannot manufacture
1045
+ fresh retries.
1046
+ """
1047
+ item = dict(row or {}) if isinstance(row, dict) else {"type": "unknown_validation_issue"}
1048
+ kind = str(item.get("type") or "unknown_validation_issue")
1049
+ groups = {
1050
+ "citation": {"citation_claim_split_required", "citation_unsupported_claim", "citation_support_unverified",
1051
+ "citation_outside_frozen_section_whitelist"},
1052
+ "causal_scope": {"causal_language_violation"},
1053
+ "quant_method": {"unsupported_quantitative_method", "quantitative_metric_mismatch", "unfrozen_p_value"},
1054
+ "study_fact": {"sample_size_mismatch", "sample_count_mismatch", "scale_points_mismatch",
1055
+ "construct_role_mismatch", "actor_data_source_relation_mismatch", "unsupported_study_procedure"},
1056
+ "enumeration": {"enumeration_contract_invalid", "enumeration_contract_mismatch"},
1057
+ "hypothesis": {"hypothesis_contract_missing_id", "hypothesis_contract_ordinal_alias",
1058
+ "hypothesis_contract_pairing_mismatch", "hypothesis_contract_unknown_id"},
1059
+ "workflow_trace": {"authorial_ai_meta_trace", "author_facing_internal_workflow_language"},
1060
+ }
1061
+ for lineage, kinds in groups.items():
1062
+ if kind in kinds:
1063
+ return lineage
1064
+ return kind
1065
+
1066
+ @classmethod
1067
+ def _section_retry_decision(cls, rows, lineage_attempts, *, hard_cap_reached: bool = False):
1068
+ """Return (may_retry, exhausted_lineages, new_lineages).
1069
+
1070
+ Each lineage receives one rewrite entitlement. A lineage that survives
1071
+ its own rewrite remains fail-closed. A genuinely new lineage discovered
1072
+ only after a previous repair receives one bounded chance, subject to the
1073
+ Section hard cap.
1074
+ """
1075
+ lineages = {cls._section_violation_lineage(row) for row in (rows or [])}
1076
+ exhausted = {name for name in lineages if int(lineage_attempts.get(name, 0)) >= 1}
1077
+ fresh = lineages - exhausted
1078
+ return bool(fresh) and not exhausted and not hard_cap_reached, exhausted, fresh
1079
+
1080
  @classmethod
1081
  def _partition_section_violations(cls, rows, *, section_name: str = "", mode: str = "", exhausted: bool = False):
1082
  blocking, advisory = [], []
 
3663
  raise RuntimeError(f"section_retry_exhausted_requires_new_lifecycle:{display_name}")
3664
 
3665
  deterministic_appendix = mode == "asq" and section_id == "appendix"
3666
+ # R4: one initial attempt plus bounded lineage-owned repair turns.
3667
+ # Four is a hard safety cap, not a blanket four-attempt entitlement.
3668
+ max_section_attempts = 1 if deterministic_appendix else 4
3669
+ lineage_attempts = {}
3670
  frozen_citation_state = self._snapshot_section_citation_state(
3671
  agent, citation_plan, display_name
3672
  )
 
3797
  )
3798
  if phase1_validation_rows:
3799
  retry_excluded_tags.update(self._citation_violation_tags(citation_semantic_rows))
3800
+ hard_cap_reached = section_attempt >= max_section_attempts
3801
+ # Severity is evaluated as exhausted only when this defect
3802
+ # lineage has already consumed its own repair entitlement.
3803
+ preview_blocking, preview_advisory = self._partition_section_violations(
3804
+ phase1_validation_rows, section_name=display_name, mode=mode, exhausted=False
3805
+ )
3806
+ may_retry, exhausted_lineages, fresh_lineages = self._section_retry_decision(
3807
+ preview_blocking, lineage_attempts, hard_cap_reached=hard_cap_reached
3808
+ )
3809
+ exhausted = hard_cap_reached or bool(exhausted_lineages)
3810
  blocking_rows, advisory_rows = self._partition_section_violations(
3811
  phase1_validation_rows, section_name=display_name, mode=mode, exhausted=exhausted
3812
  )
3813
+ if blocking_rows and may_retry:
3814
+ for lineage in fresh_lineages:
3815
+ lineage_attempts[lineage] = int(lineage_attempts.get(lineage, 0)) + 1
3816
  attempt_feedback = self._section_retry_feedback(blocking_rows)
3817
  logging.warning(
3818
+ "Unified Phase-1 Section lineage retry | unit=%s attempt=%s lineages=%s count=%s types=%s",
3819
+ display_name, section_attempt, sorted(fresh_lineages), len(blocking_rows),
3820
+ sorted({str(row.get("type") or "") for row in blocking_rows if isinstance(row, dict)}),
 
 
 
3821
  )
3822
  self._restore_section_citation_state(
3823
  agent, citation_plan, display_name, frozen_citation_state
 
3983
  hypothesis_ledger=hypothesis_ledger,
3984
  )
3985
  if post_validation_rows:
3986
+ hard_cap_reached = section_attempt >= max_section_attempts
3987
+ preview_blocking, preview_advisory = self._partition_section_violations(
3988
+ post_validation_rows, section_name=display_name, mode=mode, exhausted=False
3989
+ )
3990
+ may_retry, exhausted_lineages, fresh_lineages = self._section_retry_decision(
3991
+ preview_blocking, lineage_attempts, hard_cap_reached=hard_cap_reached
3992
+ )
3993
+ exhausted = hard_cap_reached or bool(exhausted_lineages)
3994
  blocking_rows, advisory_rows = self._partition_section_violations(
3995
  post_validation_rows, section_name=display_name, mode=mode, exhausted=exhausted
3996
  )
3997
+ if blocking_rows and may_retry:
3998
+ for lineage in fresh_lineages:
3999
+ lineage_attempts[lineage] = int(lineage_attempts.get(lineage, 0)) + 1
4000
  attempt_feedback = self._section_retry_feedback(blocking_rows)
4001
  logging.warning(
4002
+ "Post-normalization Section lineage retry | unit=%s attempt=%s lineages=%s count=%s types=%s",
4003
+ display_name, section_attempt, sorted(fresh_lineages), len(blocking_rows),
4004
  sorted({str(row.get("type") or "") for row in blocking_rows if isinstance(row, dict)}),
4005
  )
4006
  self._restore_section_citation_state(
core/pipeline_agent.py CHANGED
@@ -72,9 +72,33 @@ class CitationAuditError(DeepSeekRequestError):
72
  class SSciPipelineAgent:
73
  TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v8'
74
  MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS = 2
 
75
  PROTECTED_ENTITY_LEDGER_VERSION = 'protected-entity-ledger-v2'
76
  CANONICAL_GLOSSARY_QUALITY_VERSION = 'canonical-glossary-quality-v1'
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def __init__(self, ds_key, s2_key=""):
79
  self.ds_key = str(ds_key or "").strip()
80
  if not self.ds_key:
@@ -8268,12 +8292,26 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
8268
  claim_key = str(row.get('claim_id') or '')
8269
  if not block_key or not claim_key:
8270
  continue
 
 
 
 
 
 
 
 
 
 
 
 
8271
  self._translation_claim_repair_meta[(block_key, claim_key)] = {
8272
- 'repair_attempt_count': int(row.get('repair_attempt_count') or 0),
8273
- 'last_issue_codes': [str(code or '').strip().upper() for code in (row.get('last_issue_codes') or []) if str(code or '').strip()][:5],
8274
  'last_evidence_signature': str(row.get('last_evidence_signature') or ''),
8275
  'last_strategy': str(row.get('last_strategy') or '').strip().upper(),
8276
- 'nonconvergent_state': str(row.get('nonconvergent_state') or ''),
 
 
8277
  }
8278
 
8279
  def emit_stage(stage):
@@ -8330,7 +8368,7 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
8330
  )
8331
  repair_cycle = 0
8332
  total_claims = sum(len(self._translation_claim_units(source)) for source in source_blocks.values())
8333
- max_repair_cycles = max(1, total_claims * self.MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS)
8334
  while quality_issues and repair_cycle < max_repair_cycles:
8335
  repair_cycle += 1
8336
  failed_pairs = [
@@ -8366,26 +8404,29 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
8366
  meta = self._translation_claim_repair_meta.setdefault(evidence_key, {
8367
  'repair_attempt_count': 0, 'last_issue_codes': [], 'last_evidence_signature': '',
8368
  'last_strategy': '', 'nonconvergent_state': '',
 
8369
  })
8370
  prior_attempts = int(meta.get('repair_attempt_count') or 0)
8371
  prior_codes = set(meta.get('last_issue_codes') or [])
8372
- already_source_regenerated = str(meta.get('last_strategy') or '').upper() == 'SOURCE_REGENERATE'
8373
- already_exhausted = str(meta.get('nonconvergent_state') or '').startswith('exhausted')
8374
- if (
8375
- prior_attempts >= self.MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS
8376
- or already_exhausted
8377
- or (already_source_regenerated and bool(prior_codes & code_set))
8378
- ):
8379
- meta['nonconvergent_state'] = 'exhausted_after_source_regenerate' if already_source_regenerated else 'exhausted_after_claim_budget'
 
 
8380
  carried_exhausted.setdefault(block_id, {})[claim_id] = list(codes)
8381
  logging.info(
8382
- 'Translation claim repair entitlement exhausted: section=%s id=SP-P%s-%s attempts=%s strategy=%s',
8383
- section_name or 'section', block_id, claim_id, prior_attempts, meta.get('last_strategy') or '',
8384
  )
8385
  self._record_translation_assurance(
8386
  'CLAIM_REPAIR_EXHAUSTED', target_lang=target_lang,
8387
  block_id=f'SP-P{block_id}-{claim_id}', severity='critical', decision='ADJUDICATE',
8388
- detail={'attempts': prior_attempts, 'strategy': str(meta.get('last_strategy') or '')},
8389
  )
8390
  continue
8391
  if prior_attempts == 0 and repair_cycle > 1:
@@ -8485,9 +8526,15 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
8485
  'UNRESOLVED_SEMANTIC_DELTA' in set(repair_evidence.get('semantic_delta_codes') or [])
8486
  or 'GRAMMAR_UNSPECIFIED' in set(repair_evidence.get('grammar_evidence_codes') or [])
8487
  )
 
 
 
 
 
8488
  strategy = 'SOURCE_REGENERATE' if (
8489
  force_source_regenerate
8490
- or prior_attempts >= 1
 
8491
  or (same_defect and str(meta.get('last_strategy') or '').upper() == 'LOCAL_EDIT')
8492
  ) else 'LOCAL_EDIT'
8493
  if strategy == 'SOURCE_REGENERATE':
@@ -8517,10 +8564,17 @@ Decision must be CONFIRMED_BLOCKING or NOT_CONFIRMED. evidence_code must be one
8517
  **repair_kwargs,
8518
  )
8519
  meta['repair_attempt_count'] = int(meta.get('repair_attempt_count') or 0) + 1
 
 
 
 
 
 
 
8520
  meta['last_issue_codes'] = sorted(code_set)[:5]
8521
  meta['last_evidence_signature'] = signature
8522
  meta['last_strategy'] = strategy
8523
- meta['nonconvergent_state'] = 'source_regenerated' if strategy == 'SOURCE_REGENERATE' else ''
8524
  if 'LANGUAGE_CONTAMINATION' in code_set:
8525
  post_repair_lexical = self._translation_lexical_purity_evidence(
8526
  source_claims[claim_id], repaired_claim, target_lang, frozen_glossary
 
72
  class SSciPipelineAgent:
73
  TRANSLATION_CONTRACT_VERSION = 'hierarchical-multilingual-v8'
74
  MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS = 2
75
+ MAX_TRANSLATION_CLAIM_TOTAL_REPAIRS = 6
76
  PROTECTED_ENTITY_LEDGER_VERSION = 'protected-entity-ledger-v2'
77
  CANONICAL_GLOSSARY_QUALITY_VERSION = 'canonical-glossary-quality-v1'
78
 
79
+ @staticmethod
80
+ def _translation_defect_family(codes):
81
+ """Collapse translation QA codes into stable repair lineages.
82
+
83
+ R4 gives each claim+defect family its own bounded entitlement while
84
+ retaining a claim-level hard cap. Closely related semantic codes share
85
+ one family so re-labeling the same defect cannot reset the budget.
86
+ """
87
+ normalized = {str(code or '').strip().upper() for code in (codes or []) if str(code or '').strip()}
88
+ families = []
89
+ if normalized & {'SEMANTIC_DRIFT','NEGATION_DRIFT','CAUSALITY_STRENGTH_DRIFT','UNCERTAINTY_DRIFT','OMITTED_CLAIM','ADDED_CLAIM'}:
90
+ families.append('SEMANTIC')
91
+ if 'GRAMMAR' in normalized:
92
+ families.append('GRAMMAR')
93
+ if 'TERMINOLOGY' in normalized:
94
+ families.append('TERMINOLOGY')
95
+ if 'LANGUAGE_CONTAMINATION' in normalized:
96
+ families.append('LANGUAGE_CONTAMINATION')
97
+ for code in sorted(normalized):
98
+ if code not in {'SEMANTIC_DRIFT','NEGATION_DRIFT','CAUSALITY_STRENGTH_DRIFT','UNCERTAINTY_DRIFT','OMITTED_CLAIM','ADDED_CLAIM','GRAMMAR','TERMINOLOGY','LANGUAGE_CONTAMINATION'}:
99
+ families.append(code)
100
+ return tuple(dict.fromkeys(families)) or ('UNKNOWN',)
101
+
102
  def __init__(self, ds_key, s2_key=""):
103
  self.ds_key = str(ds_key or "").strip()
104
  if not self.ds_key:
 
8292
  claim_key = str(row.get('claim_id') or '')
8293
  if not block_key or not claim_key:
8294
  continue
8295
+ prior_codes = [str(code or '').strip().upper() for code in (row.get('last_issue_codes') or []) if str(code or '').strip()][:5]
8296
+ family_attempt_counts = dict(row.get('family_attempt_counts') or {})
8297
+ family_last_strategy = dict(row.get('family_last_strategy') or {})
8298
+ legacy_state = str(row.get('nonconvergent_state') or '')
8299
+ legacy_attempts = int(row.get('repair_attempt_count') or 0)
8300
+ # R4 migration rule: an R3 checkpoint that had already exhausted a
8301
+ # claim/family remains fail-closed. New lineage ownership must never
8302
+ # resurrect a previously terminal defect.
8303
+ if not family_attempt_counts and (legacy_state.startswith('exhausted') or legacy_attempts >= self.MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS):
8304
+ for family in self._translation_defect_family(prior_codes):
8305
+ family_attempt_counts[family] = self.MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS
8306
+ family_last_strategy[family] = str(row.get('last_strategy') or '').strip().upper()
8307
  self._translation_claim_repair_meta[(block_key, claim_key)] = {
8308
+ 'repair_attempt_count': legacy_attempts,
8309
+ 'last_issue_codes': prior_codes,
8310
  'last_evidence_signature': str(row.get('last_evidence_signature') or ''),
8311
  'last_strategy': str(row.get('last_strategy') or '').strip().upper(),
8312
+ 'nonconvergent_state': legacy_state,
8313
+ 'family_attempt_counts': family_attempt_counts,
8314
+ 'family_last_strategy': family_last_strategy,
8315
  }
8316
 
8317
  def emit_stage(stage):
 
8368
  )
8369
  repair_cycle = 0
8370
  total_claims = sum(len(self._translation_claim_units(source)) for source in source_blocks.values())
8371
+ max_repair_cycles = max(1, total_claims * self.MAX_TRANSLATION_CLAIM_TOTAL_REPAIRS)
8372
  while quality_issues and repair_cycle < max_repair_cycles:
8373
  repair_cycle += 1
8374
  failed_pairs = [
 
8404
  meta = self._translation_claim_repair_meta.setdefault(evidence_key, {
8405
  'repair_attempt_count': 0, 'last_issue_codes': [], 'last_evidence_signature': '',
8406
  'last_strategy': '', 'nonconvergent_state': '',
8407
+ 'family_attempt_counts': {}, 'family_last_strategy': {},
8408
  })
8409
  prior_attempts = int(meta.get('repair_attempt_count') or 0)
8410
  prior_codes = set(meta.get('last_issue_codes') or [])
8411
+ families = self._translation_defect_family(code_set)
8412
+ family_counts = dict(meta.get('family_attempt_counts') or {})
8413
+ family_strategies = dict(meta.get('family_last_strategy') or {})
8414
+ exhausted_families = {
8415
+ family for family in families
8416
+ if int(family_counts.get(family, 0)) >= self.MAX_TRANSLATION_CLAIM_REPAIR_ATTEMPTS
8417
+ }
8418
+ claim_hard_cap = prior_attempts >= self.MAX_TRANSLATION_CLAIM_TOTAL_REPAIRS
8419
+ if exhausted_families or claim_hard_cap:
8420
+ meta['nonconvergent_state'] = 'exhausted_family:' + ','.join(sorted(exhausted_families)) if exhausted_families else 'exhausted_after_claim_hard_cap'
8421
  carried_exhausted.setdefault(block_id, {})[claim_id] = list(codes)
8422
  logging.info(
8423
+ 'Translation claim defect-family entitlement exhausted: section=%s id=SP-P%s-%s attempts=%s families=%s',
8424
+ section_name or 'section', block_id, claim_id, prior_attempts, sorted(exhausted_families or set(families)),
8425
  )
8426
  self._record_translation_assurance(
8427
  'CLAIM_REPAIR_EXHAUSTED', target_lang=target_lang,
8428
  block_id=f'SP-P{block_id}-{claim_id}', severity='critical', decision='ADJUDICATE',
8429
+ detail={'attempts': prior_attempts, 'families': '|'.join(sorted(exhausted_families or set(families)))},
8430
  )
8431
  continue
8432
  if prior_attempts == 0 and repair_cycle > 1:
 
8526
  'UNRESOLVED_SEMANTIC_DELTA' in set(repair_evidence.get('semantic_delta_codes') or [])
8527
  or 'GRAMMAR_UNSPECIFIED' in set(repair_evidence.get('grammar_evidence_codes') or [])
8528
  )
8529
+ family_prior_attempts = max(int(family_counts.get(family, 0)) for family in families)
8530
+ family_prior_source_regenerated = any(
8531
+ str(family_strategies.get(family) or '').upper() == 'SOURCE_REGENERATE'
8532
+ for family in families
8533
+ )
8534
  strategy = 'SOURCE_REGENERATE' if (
8535
  force_source_regenerate
8536
+ or family_prior_attempts >= 1
8537
+ or family_prior_source_regenerated
8538
  or (same_defect and str(meta.get('last_strategy') or '').upper() == 'LOCAL_EDIT')
8539
  ) else 'LOCAL_EDIT'
8540
  if strategy == 'SOURCE_REGENERATE':
 
8564
  **repair_kwargs,
8565
  )
8566
  meta['repair_attempt_count'] = int(meta.get('repair_attempt_count') or 0) + 1
8567
+ family_counts = dict(meta.get('family_attempt_counts') or family_counts)
8568
+ family_strategies = dict(meta.get('family_last_strategy') or family_strategies)
8569
+ for family in families:
8570
+ family_counts[family] = int(family_counts.get(family, 0)) + 1
8571
+ family_strategies[family] = strategy
8572
+ meta['family_attempt_counts'] = family_counts
8573
+ meta['family_last_strategy'] = family_strategies
8574
  meta['last_issue_codes'] = sorted(code_set)[:5]
8575
  meta['last_evidence_signature'] = signature
8576
  meta['last_strategy'] = strategy
8577
+ meta['nonconvergent_state'] = ''
8578
  if 'LANGUAGE_CONTAMINATION' in code_set:
8579
  post_repair_lexical = self._translation_lexical_purity_evidence(
8580
  source_claims[claim_id], repaired_claim, target_lang, frozen_glossary
core/quantitative_engine.py CHANGED
@@ -1091,6 +1091,47 @@ Keywords: {keywords}"""
1091
  """Compatibility wrapper for older call sites; uses the shared tri-state contract."""
1092
  return cls.classify_quantitative_causal_sentence(sentence) == "EXPLICIT_CAUSAL_LIMITATION"
1093
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1094
  @classmethod
1095
  def inspect_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
1096
  """Pure AMJ quantitative Evidence Ledger inspection; never rewrites prose."""
@@ -1162,7 +1203,13 @@ Keywords: {keywords}"""
1162
  if pattern.search(sentence):
1163
  violations.append({"type":"construct_role_mismatch","role":role,"observed":candidate,"expected":expected,"preview":sentence.strip()[:180]})
1164
  if strict:
1165
- causal_state = cls.classify_quantitative_causal_sentence(sentence)
 
 
 
 
 
 
1166
  if causal_state == "AFFIRMATIVE_CAUSAL":
1167
  violations.append({
1168
  "type":"causal_language_violation",
 
1091
  """Compatibility wrapper for older call sites; uses the shared tri-state contract."""
1092
  return cls.classify_quantitative_causal_sentence(sentence) == "EXPLICIT_CAUSAL_LIMITATION"
1093
 
1094
+ @classmethod
1095
+ def _is_methodological_bias_mechanism_sentence(cls, sentence, known_constructs=None):
1096
+ """Recognize measurement/method-bias mechanisms without weakening construct causality.
1097
+
1098
+ R4 separates methodological statements about how measurement conditions
1099
+ may bias statistical quantities from substantive claims that one study
1100
+ construct causes another. The exemption is intentionally narrow: a
1101
+ measurement/source-bias actor and a statistical/measurement target must
1102
+ both be present. Explicit construct-level causal language still wins.
1103
+ """
1104
+ value = re.sub(r"\s+", " ", str(sentence or "")).strip().casefold()
1105
+ if not value:
1106
+ return False
1107
+ methodological_actor = re.search(
1108
+ r"\b(?:self[- ]reports?|single\s+questionnaire|same[- ]source|common\s+method(?:\s+bias)?|"
1109
+ r"response\s+styles?|social\s+desirability|measurement\s+error|item\s+wording|"
1110
+ r"common\s+rater|method\s+variance|single[- ]source\s+measurement)\b", value, re.I
1111
+ )
1112
+ statistical_target = re.search(
1113
+ r"\b(?:correlations?|covariances?|associations?|estimates?|coefficients?|variance|"
1114
+ r"standard\s+errors?|reliability|validity|measurement\s+error|method\s+variance)\b", value, re.I
1115
+ )
1116
+ mechanism = re.search(
1117
+ r"\b(?:inflate|inflates|inflated|attenuate|attenuates|attenuated|bias|biases|biased|"
1118
+ r"distort|distorts|distorted|increase|increases|increased|decrease|decreases|decreased|"
1119
+ r"contribute|contributes|contributed|induce|induces|induced)\b", value, re.I
1120
+ )
1121
+ if not (methodological_actor and statistical_target and mechanism):
1122
+ return False
1123
+
1124
+ # Never let a methodological phrase shield an explicit relation between
1125
+ # named study constructs.
1126
+ constructs = [str(x or '').strip().casefold() for x in (known_constructs or []) if str(x or '').strip()]
1127
+ named_hits = [c for c in constructs if c and c in value]
1128
+ if len(named_hits) >= 2 and re.search(
1129
+ r"\b(?:causes?|leads?\s+to|drives?|determines?|produces?|results?\s+in|yields?|generates?)\b",
1130
+ value, re.I
1131
+ ):
1132
+ return False
1133
+ return True
1134
+
1135
  @classmethod
1136
  def inspect_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
1137
  """Pure AMJ quantitative Evidence Ledger inspection; never rewrites prose."""
 
1203
  if pattern.search(sentence):
1204
  violations.append({"type":"construct_role_mismatch","role":role,"observed":candidate,"expected":expected,"preview":sentence.strip()[:180]})
1205
  if strict:
1206
+ methodological_bias_scope = cls._is_methodological_bias_mechanism_sentence(
1207
+ sentence, known_constructs
1208
+ )
1209
+ causal_state = (
1210
+ "NON_CAUSAL" if methodological_bias_scope
1211
+ else cls.classify_quantitative_causal_sentence(sentence)
1212
+ )
1213
  if causal_state == "AFFIRMATIVE_CAUSAL":
1214
  violations.append({
1215
  "type":"causal_language_violation",