S1-R4 Method Authority Guard + Balanced Final Abstract

#125
core/generation_runner.py CHANGED
@@ -1242,11 +1242,32 @@ class GenerationRunner:
1242
  sentence = relation_sentence(moderation[0])
1243
  if sentence:
1244
  out.append(sentence)
1245
- if "final_abstract_missing_empirical_finding" in wanted and not out:
1246
- candidate = next((item for item in items if relation_results.get(str(item.get("relation_id") or ""))), None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1247
  sentence = relation_sentence(candidate or {})
1248
  if sentence:
1249
- out.append(sentence)
 
1250
 
1251
  if "final_abstract_missing_indirect_finding" in wanted:
1252
  indirect_rows = [dict(x) for x in (ledger.get("indirect_analyses") or []) if isinstance(x, dict)]
 
1242
  sentence = relation_sentence(moderation[0])
1243
  if sentence:
1244
  out.append(sentence)
1245
+ if "final_abstract_missing_empirical_finding" in wanted:
1246
+ # Preserve representativeness even when the abstract already
1247
+ # contains null/unsupported findings. Prefer one supported
1248
+ # non-moderation relation so the fallback communicates a
1249
+ # central positive/negative empirical result rather than
1250
+ # becoming a list of only non-significant findings.
1251
+ supported = [
1252
+ item for item in items
1253
+ if str(item.get("relation") or "").strip().lower() != "moderation"
1254
+ and cls._final_abstract_ci_status(
1255
+ dict(relation_results.get(str(item.get("relation_id") or "")) or {})
1256
+ ) == "supported"
1257
+ ]
1258
+ candidate = next(
1259
+ (item for item in supported if str(item.get("outcome") or "").strip()),
1260
+ supported[0] if supported else None,
1261
+ )
1262
+ if candidate is None and not out:
1263
+ candidate = next(
1264
+ (item for item in items if relation_results.get(str(item.get("relation_id") or ""))),
1265
+ None,
1266
+ )
1267
  sentence = relation_sentence(candidate or {})
1268
  if sentence:
1269
+ if sentence not in out:
1270
+ out.append(sentence)
1271
 
1272
  if "final_abstract_missing_indirect_finding" in wanted:
1273
  indirect_rows = [dict(x) for x in (ledger.get("indirect_analyses") or []) if isinstance(x, dict)]
core/pipeline_agent.py CHANGED
@@ -5088,6 +5088,84 @@ INPUT:
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,6 +5180,11 @@ INPUT:
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,6 +5238,9 @@ INPUT:
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,6 +5277,18 @@ INPUT:
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,6 +5351,9 @@ INPUT:
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,6 +5388,16 @@ INPUT:
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)
 
5088
  return True
5089
  return False
5090
 
5091
+ @staticmethod
5092
+ def _has_explicit_current_qualitative_study_anchor(sentence):
5093
+ """Identify an explicit current-study owner even in a cited sentence.
5094
+
5095
+ A method citation may justify a procedure, but it must not make this
5096
+ manuscript's own empirical/procedural clause externally owned. Keep
5097
+ this narrower than the general qualitative ownership heuristic so a
5098
+ cited phrase such as ``the study conducted interviews`` is not
5099
+ accidentally reclassified as the current manuscript.
5100
+ """
5101
+ value = str(sentence or "")
5102
+ return bool(re.search(
5103
+ r"(?i)\b(?:we|our\s+study|this\s+study|the\s+present\s+study|"
5104
+ r"research\s+team|our\s+data|our\s+analysis|our\s+interviews?|"
5105
+ r"our\s+focus\s+groups?|our\s+observations?|in\s+this\s+study|"
5106
+ r"in\s+our\s+study)\b",
5107
+ value,
5108
+ ))
5109
+
5110
+ @staticmethod
5111
+ def _normalize_qualitative_analytic_design_identity(value):
5112
+ folded = re.sub(r"\s+", " ", str(value or "")).strip().casefold()
5113
+ if not folded:
5114
+ return ""
5115
+ if "gioia" in folded:
5116
+ return "gioia"
5117
+ if "grounded theory" in folded:
5118
+ return "grounded_theory"
5119
+ if "comparative case" in folded:
5120
+ return "comparative_case"
5121
+ if "longitudinal" in folded and "qualitative" in folded:
5122
+ return "longitudinal_qualitative"
5123
+ if "thematic" in folded:
5124
+ return "thematic_process"
5125
+ if "process analysis" in folded:
5126
+ return "process_analysis"
5127
+ return folded
5128
+
5129
+ @classmethod
5130
+ def _performed_qualitative_analytic_design_identity(cls, sentence):
5131
+ """Return a performed current-study qualitative design, if explicit.
5132
+
5133
+ This is a prose-level evidence guard, not a new method router. It
5134
+ prevents a cited method name from silently replacing the analytic design
5135
+ frozen in the qualitative evidence packet.
5136
+ """
5137
+ value = re.sub(r"\s+", " ", str(sentence or "")).strip()
5138
+ if not value or not cls._has_explicit_current_qualitative_study_anchor(value):
5139
+ return ""
5140
+ candidates = (
5141
+ ("gioia", r"\bGioia(?:-style)?(?:\s+inductive\s+coding|\s+methodology|\s+method|\s+approach)?\b"),
5142
+ ("grounded_theory", r"\bgrounded\s+theory\b"),
5143
+ ("comparative_case", r"\bcomparative\s+case\s+analysis\b"),
5144
+ ("longitudinal_qualitative", r"\blongitudinal\s+qualitative\s+analysis\b"),
5145
+ ("process_analysis", r"\bprocess\s+analysis\b"),
5146
+ ("thematic_process", r"\b(?:iterative\s+)?thematic(?:/process)?\s+analysis\b|\bthematic\s+analysis\b"),
5147
+ )
5148
+ for identity, pattern in candidates:
5149
+ match = re.search(pattern, value, flags=re.I)
5150
+ if not match:
5151
+ continue
5152
+ before = value[max(0, match.start() - 120):match.start()]
5153
+ if re.search(
5154
+ r"(?i)\b(?:did\s+not|does\s+not|do\s+not|was\s+not|were\s+not|"
5155
+ r"not|without|never)\b[^.;:]{0,45}$",
5156
+ before,
5157
+ ):
5158
+ continue
5159
+ full_before = value[:match.start()]
5160
+ if re.search(
5161
+ r"(?i)\b(?:use|uses|used|employ|employs|employed|apply|applies|applied|"
5162
+ r"adopt|adopts|adopted|follow|follows|followed|conduct|conducts|conducted|"
5163
+ r"analy[sz]e|analy[sz]es|analy[sz]ed|code|codes|coded)\b[^.;:]{0,180}$",
5164
+ full_before,
5165
+ ):
5166
+ return identity
5167
+ return ""
5168
+
5169
  @classmethod
5170
  def _is_study_owned_qualitative_claim(cls, sentence, section_name=""):
5171
  """Conservatively identify only this manuscript's empirical/procedural claims.
 
5180
  return False
5181
  if re.match(r'^["“‘\']', stripped) or re.search(r"\bQUOTE-\d+\b", value, flags=re.I):
5182
  return False
5183
+ # Explicit current-study ownership dominates an appended method or
5184
+ # theory citation. The cited source may justify the procedure, but it
5185
+ # cannot own the statement that *this* study performed it.
5186
+ if cls._has_explicit_current_qualitative_study_anchor(value):
5187
+ return True
5188
  if cls._has_external_literature_marker(value):
5189
  return False
5190
  if re.search(r"(?i)\b(?:prior|previous|earlier|existing)\s+(?:research|stud(?:y|ies)|literature)\b", value):
 
5238
  "ethics": [str(x or "").strip().casefold() for x in (facts.get("ethics_procedures") or []) if str(x or "").strip()],
5239
  "reflexivity": [str(x or "").strip().casefold() for x in (facts.get("reflexivity_procedures") or []) if str(x or "").strip()],
5240
  }
5241
+ authorized_design = cls._normalize_qualitative_analytic_design_identity(
5242
+ facts.get("analytic_design") or facts.get("analysis_method")
5243
+ )
5244
  procedure_patterns = {
5245
  "trustworthiness": r"\bmember\s+check(?:ing|s)?\b|\bpeer\s+debrief(?:ing|ed)?\b|\baudit\s+trail\b|\bexternal\s+audit\b",
5246
  "ethics": r"\binformed\s+consent\b|\bethics?\s+approval\b|\binstitutional\s+review\s+board\b|\bIRB\b|\bapproval\s+(?:number|code)\b",
 
5277
  if not core.strip() or not cls._is_study_owned_qualitative_claim(core, section_name):
5278
  continue
5279
 
5280
+ observed_design = cls._performed_qualitative_analytic_design_identity(core)
5281
+ if observed_design and authorized_design and observed_design != authorized_design:
5282
+ sig = ("analytic_design", observed_design)
5283
+ if sig not in seen:
5284
+ seen.add(sig)
5285
+ violations.append({
5286
+ "type":"unsupported_qualitative_analytic_design",
5287
+ "observed":observed_design,
5288
+ "expected":authorized_design,
5289
+ "preview":core.strip()[:180],
5290
+ })
5291
+
5292
  # Check every explicit procedure mention; do not stop after the first.
5293
  for category, pattern in procedure_patterns.items():
5294
  for match in re.finditer(pattern, core, flags=re.I):
 
5351
  "ethics": [str(x or "").strip() for x in (facts.get("ethics_procedures") or []) if str(x or "").strip()],
5352
  "reflexivity": [str(x or "").strip() for x in (facts.get("reflexivity_procedures") or []) if str(x or "").strip()],
5353
  }
5354
+ authorized_design = cls._normalize_qualitative_analytic_design_identity(
5355
+ facts.get("analytic_design") or facts.get("analysis_method")
5356
+ )
5357
  allowed_by_actor = {}
5358
  canonical_relations = []
5359
  canonical_by_actor = {}
 
5388
  lower = core.casefold()
5389
  if not core.strip() or not cls._is_study_owned_qualitative_claim(core, section_name):
5390
  continue
5391
+ observed_design = cls._performed_qualitative_analytic_design_identity(core)
5392
+ if observed_design and authorized_design and observed_design != authorized_design:
5393
+ replacements.append((start, end, ""))
5394
+ diagnostics.append({
5395
+ "type":"unsupported_qualitative_analytic_design_removed",
5396
+ "observed":observed_design,
5397
+ "expected":authorized_design,
5398
+ "preview":core.strip()[:180],
5399
+ })
5400
+ continue
5401
  removed = False
5402
  for category, pattern in procedure_patterns.items():
5403
  procedure_match = re.search(pattern, core, flags=re.I)
core/quantitative_engine.py CHANGED
@@ -1024,6 +1024,24 @@ Keywords: {keywords}"""
1024
  return "study"
1025
  return "unknown"
1026
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1027
  @staticmethod
1028
  def _unique_numeric(pattern, evidence, *, integer=False):
1029
  values = []
@@ -1182,7 +1200,41 @@ Keywords: {keywords}"""
1182
  if explicit_nonuse:
1183
  return {"term": label, "category": category, "state": "NOT_PERFORMED_OR_LIMITATION"}
1184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1185
  performed = bool(
 
 
1186
  re.search(
1187
  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}$",
1188
  before,
@@ -1917,11 +1969,34 @@ Keywords: {keywords}"""
1917
 
1918
  for _start, _end, sentence in cls._sentence_spans(value):
1919
  ownership = cls._quantitative_sentence_ownership(sentence, section_name, known_constructs)
 
 
 
 
 
 
 
 
 
 
1920
  method_scope_unknown = (
1921
  strict and ownership == "unknown"
1922
  and bool(re.search(r"(?i)method|measure", str(section_name or "")))
1923
  and cls._first_performed_unowned_quantitative_term(sentence, forbidden) is not None
1924
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
1925
  if ownership not in {"study", "result"} and not method_scope_unknown:
1926
  continue
1927
  if sample_size:
@@ -2065,11 +2140,31 @@ Keywords: {keywords}"""
2065
  replacements = []
2066
  for start, end, sentence in cls._sentence_spans(value):
2067
  ownership = cls._quantitative_sentence_ownership(sentence, section_name, known_constructs)
 
 
 
 
 
 
 
 
 
 
2068
  method_scope_unknown = (
2069
  strict and ownership == "unknown"
2070
  and bool(re.search(r"(?i)method|measure", str(section_name or "")))
2071
  and cls._first_performed_unowned_quantitative_term(sentence, forbidden) is not None
2072
  )
 
 
 
 
 
 
 
 
 
 
2073
  if ownership not in {"study", "result"} and not method_scope_unknown:
2074
  continue
2075
  revised = sentence
 
1024
  return "study"
1025
  return "unknown"
1026
 
1027
+ @staticmethod
1028
+ def _has_explicit_current_study_anchor(sentence):
1029
+ """Return True only for an explicit *current manuscript* ownership anchor.
1030
+
1031
+ A literature citation does not transfer ownership of a clause such as
1032
+ ``We tested ... (Author, year)`` to the cited source. This helper is
1033
+ intentionally narrower than the general sentence-ownership heuristic:
1034
+ generic phrases such as ``the study`` are excluded because they may
1035
+ refer to a cited study.
1036
+ """
1037
+ value = str(sentence or "")
1038
+ return bool(re.search(
1039
+ r"(?i)\b(?:we|our\s+study|this\s+study|the\s+present\s+study|"
1040
+ r"our\s+analysis|our\s+model|our\s+sample|our\s+results|"
1041
+ r"in\s+this\s+study|in\s+our\s+study)\b",
1042
+ value,
1043
+ ))
1044
+
1045
  @staticmethod
1046
  def _unique_numeric(pattern, evidence, *, integer=False):
1047
  values = []
 
1200
  if explicit_nonuse:
1201
  return {"term": label, "category": category, "state": "NOT_PERFORMED_OR_LIMITATION"}
1202
 
1203
+ # The original local-window classifier can miss a long current-study
1204
+ # frame such as ``We tested the hypotheses ... using partial least
1205
+ # squares structural equation modeling (PLS-SEM)`` because the action
1206
+ # verb may sit more than 70 characters before the forbidden term. A
1207
+ # citation appended to the same sentence must not make that performed
1208
+ # method claim externally owned. Detect the explicit current-study
1209
+ # action across the whole pre-term clause before applying the narrower
1210
+ # legacy patterns below.
1211
+ full_before = folded[:match.start()]
1212
+ if re.search(
1213
+ r"\b(?:did\s+not|does\s+not|do\s+not|was\s+not|were\s+not|never)\s+"
1214
+ r"(?!only\b)(?:use|uses|used|employ|employs|employed|conduct|conducts|conducted|"
1215
+ r"perform|performs|performed|estimate|estimates|estimated|test|tests|tested|"
1216
+ r"assess|assesses|assessed|evaluate|evaluates|evaluated|implement|implements|"
1217
+ r"implemented|apply|applies|applied|fit|fits|fitted|model|models|modeled|modelled)\b"
1218
+ r"[^.;:]{0,240}$",
1219
+ full_before,
1220
+ ):
1221
+ return {"term": label, "category": category, "state": "NOT_PERFORMED_OR_LIMITATION"}
1222
+ current_study_performed = bool(
1223
+ cls._has_explicit_current_study_anchor(text)
1224
+ and re.search(
1225
+ r"\b(?:use|uses|used|employ|employs|employed|conduct|conducts|conducted|"
1226
+ r"perform|performs|performed|estimate|estimates|estimated|test|tests|tested|"
1227
+ r"assess|assesses|assessed|evaluate|evaluates|evaluated|implement|implements|"
1228
+ r"implemented|apply|applies|applied|ran|run|runs|fit|fits|fitted|model|models|"
1229
+ r"modeled|modelled|control|controls|controlled|correct|corrects|corrected|"
1230
+ r"address|addresses|addressed)\b[^.;:]{0,240}$",
1231
+ full_before,
1232
+ )
1233
+ )
1234
+
1235
  performed = bool(
1236
+ current_study_performed
1237
+ or
1238
  re.search(
1239
  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}$",
1240
  before,
 
1969
 
1970
  for _start, _end, sentence in cls._sentence_spans(value):
1971
  ownership = cls._quantitative_sentence_ownership(sentence, section_name, known_constructs)
1972
+ current_study_method_frame = (
1973
+ cls._first_performed_unowned_quantitative_term(sentence, forbidden)
1974
+ if strict and cls._has_explicit_current_study_anchor(sentence)
1975
+ else None
1976
+ )
1977
+ method_scope_hybrid = bool(
1978
+ strict
1979
+ and ownership == "external"
1980
+ and current_study_method_frame is not None
1981
+ )
1982
  method_scope_unknown = (
1983
  strict and ownership == "unknown"
1984
  and bool(re.search(r"(?i)method|measure", str(section_name or "")))
1985
  and cls._first_performed_unowned_quantitative_term(sentence, forbidden) is not None
1986
  )
1987
+ if method_scope_hybrid:
1988
+ violations.append({
1989
+ "type":"unsupported_quantitative_method",
1990
+ "method":current_study_method_frame.get("term"),
1991
+ "claim_state":current_study_method_frame.get("state"),
1992
+ "method_category":current_study_method_frame.get("category"),
1993
+ "preview":sentence.strip()[:180],
1994
+ })
1995
+ # The literature-cited part remains externally owned. Do not
1996
+ # run unrelated metric/role rewrites across this hybrid
1997
+ # sentence; the method violation alone is sufficient to force
1998
+ # bounded regeneration/fail-closed handling.
1999
+ continue
2000
  if ownership not in {"study", "result"} and not method_scope_unknown:
2001
  continue
2002
  if sample_size:
 
2140
  replacements = []
2141
  for start, end, sentence in cls._sentence_spans(value):
2142
  ownership = cls._quantitative_sentence_ownership(sentence, section_name, known_constructs)
2143
+ current_study_method_frame = (
2144
+ cls._first_performed_unowned_quantitative_term(sentence, forbidden)
2145
+ if strict and cls._has_explicit_current_study_anchor(sentence)
2146
+ else None
2147
+ )
2148
+ method_scope_hybrid = bool(
2149
+ strict
2150
+ and ownership == "external"
2151
+ and current_study_method_frame is not None
2152
+ )
2153
  method_scope_unknown = (
2154
  strict and ownership == "unknown"
2155
  and bool(re.search(r"(?i)method|measure", str(section_name or "")))
2156
  and cls._first_performed_unowned_quantitative_term(sentence, forbidden) is not None
2157
  )
2158
+ if method_scope_hybrid:
2159
+ diagnostics.append({
2160
+ "type":"unsupported_quantitative_method_removed",
2161
+ "method":current_study_method_frame.get("term"),
2162
+ "claim_state":current_study_method_frame.get("state"),
2163
+ "method_category":current_study_method_frame.get("category"),
2164
+ "preview":sentence.strip()[:180],
2165
+ })
2166
+ replacements.append((start, end, ""))
2167
+ continue
2168
  if ownership not in {"study", "result"} and not method_scope_unknown:
2169
  continue
2170
  revised = sentence