A5-R3-R7-R2C-R2 adaptive study design scoped deployment

#118
core/generation_runner.py CHANGED
@@ -998,15 +998,23 @@ class GenerationRunner:
998
  diagnostics = quant_engine.inspect_quantitative_study_fact_contract(
999
  value, data_metadata, section_name=section_name
1000
  )
1001
- return value, list(diagnostics or [])
 
 
 
 
1002
 
1003
  @staticmethod
1004
  def _study_fact_violations(mode: str, agent, quant_engine, text: str, data_metadata: dict, *, section_name: str):
 
1005
  if str(mode or '') == 'asq':
1006
- return list(agent.inspect_qualitative_study_fact_contract(text, data_metadata, section_name=section_name) or [])
1007
- if str(mode or '') == 'amj':
1008
- return list(quant_engine.inspect_quantitative_study_fact_contract(text, data_metadata, section_name=section_name) or [])
1009
- return []
 
 
 
1010
 
1011
  @staticmethod
1012
  def _section_retry_feedback(violations, *, limit=8) -> str:
@@ -1072,6 +1080,10 @@ class GenerationRunner:
1072
  "hypothesis_contract_missing_id", "hypothesis_contract_ordinal_alias",
1073
  "hypothesis_contract_pairing_mismatch", "hypothesis_contract_unknown_id",
1074
  "citation_outside_frozen_section_whitelist", "authorial_ai_meta_trace",
 
 
 
 
1075
  }
1076
  if kind in always_fatal:
1077
  return "FATAL"
@@ -1115,6 +1127,12 @@ class GenerationRunner:
1115
  "hypothesis": {"hypothesis_contract_missing_id", "hypothesis_contract_ordinal_alias",
1116
  "hypothesis_contract_pairing_mismatch", "hypothesis_contract_unknown_id"},
1117
  "workflow_trace": {"authorial_ai_meta_trace", "author_facing_internal_workflow_language"},
 
 
 
 
 
 
1118
  }
1119
  for lineage, kinds in groups.items():
1120
  if kind in kinds:
@@ -1438,8 +1456,13 @@ class GenerationRunner:
1438
  hid = str(item.get("hypothesis_id") or "").strip()
1439
  predictor = str(item.get("predictor") or "").strip()
1440
  outcome = str(item.get("outcome") or "").strip()
 
 
1441
  if hid and predictor and outcome:
1442
- lines.append(f"{hid}: {predictor} is associated with {outcome}.")
 
 
 
1443
  if not lines:
1444
  return ""
1445
  indirect = dict(record.get("indirect_analysis") or {})
@@ -1470,8 +1493,13 @@ class GenerationRunner:
1470
  hid = str(item.get("hypothesis_id") or "").strip().upper()
1471
  predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
1472
  outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
 
 
1473
  if hid and predictor and outcome:
1474
- lines.append(f"{hid}: {predictor} is associated with {outcome}.")
 
 
 
1475
  return "\n".join(lines)
1476
 
1477
  @staticmethod
@@ -1526,7 +1554,7 @@ class GenerationRunner:
1526
  })
1527
  all_constructs = {
1528
  re.sub(r"\s+", " ", str(item.get(key) or "")).strip()
1529
- for item in items for key in ("predictor", "outcome")
1530
  if str(item.get(key) or "").strip()
1531
  }
1532
  for start, end, sentence in SSciPipelineAgent._sentence_spans(value):
@@ -1543,6 +1571,7 @@ class GenerationRunner:
1543
  continue
1544
  predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
1545
  outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
 
1546
  lower_sentence = re.sub(r"\s+", " ", sentence).casefold()
1547
  mentioned_constructs = {
1548
  term for term in all_constructs if term.casefold() in lower_sentence
@@ -1550,8 +1579,9 @@ class GenerationRunner:
1550
  # Pairing identity is global, not limited to the theory-definition
1551
  # sections. Require at least two named constructs so a bare reference
1552
  # such as "H1 was supported" cannot be misclassified as remapping.
1553
- if len(mentioned_constructs) >= 2 and predictor and outcome and (
1554
- predictor.casefold() not in lower_sentence or outcome.casefold() not in lower_sentence
 
1555
  ):
1556
  rows.append({
1557
  "type": "hypothesis_contract_pairing_mismatch",
@@ -3189,10 +3219,14 @@ class GenerationRunner:
3189
  hypothesis_ledger = dict(data_metadata.get("hypothesis_ledger") or {})
3190
  expected_hypothesis_version = str(getattr(quant_engine, "HYPOTHESIS_LEDGER_VERSION", ""))
3191
  if hypothesis_ledger.get("version") != expected_hypothesis_version:
3192
- constructs = dict(data_metadata.get("constructs") or {})
3193
- rebuilt_hypotheses = quant_engine.build_hypothesis_ledger([
3194
- constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")
3195
- ])
 
 
 
 
3196
  if not rebuilt_hypotheses:
3197
  raise RuntimeError("quantitative_hypothesis_ledger_rebuild_failed")
3198
  data_metadata["hypothesis_ledger"] = rebuilt_hypotheses
@@ -3203,12 +3237,16 @@ class GenerationRunner:
3203
  front_contract = dict(data_metadata.get("front_matter_contract") or {})
3204
  expected_front_version = str(getattr(quant_engine, "FRONT_MATTER_CONTRACT_VERSION", ""))
3205
  if front_contract.get("version") != expected_front_version:
3206
- constructs = dict(data_metadata.get("constructs") or {})
3207
- rebuilt_front = quant_engine.build_front_matter_contract(
3208
- topic, keywords, [
3209
- constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")
3210
- ]
3211
- )
 
 
 
 
3212
  if rebuilt_front:
3213
  data_metadata["front_matter_contract"] = rebuilt_front
3214
  else:
@@ -4189,7 +4227,7 @@ class GenerationRunner:
4189
  table_rule = (
4190
  "AUTHORITATIVE TABLE CONTRACT: Do not create, reproduce, renumber, or alter any Markdown statistical table. "
4191
  "Required statistical tables are handled separately from narrative prose. "
4192
- "You may discuss and refer to Table 1-Table 4 where relevant, but do not invent an additional table or change a table number."
4193
  )
4194
  elif sec["is_method_or_result"] and mode == "amj":
4195
  table_rule = "Present only study-supported statistical evidence; do not invent tables or numerical results."
 
998
  diagnostics = quant_engine.inspect_quantitative_study_fact_contract(
999
  value, data_metadata, section_name=section_name
1000
  )
1001
+ diagnostics = list(diagnostics or [])
1002
+ scope_inspector = getattr(agent, "inspect_study_design_scope_contract", None)
1003
+ if callable(scope_inspector):
1004
+ diagnostics.extend(scope_inspector(value, data_metadata, section_name=section_name) or [])
1005
+ return value, diagnostics
1006
 
1007
  @staticmethod
1008
  def _study_fact_violations(mode: str, agent, quant_engine, text: str, data_metadata: dict, *, section_name: str):
1009
+ rows = []
1010
  if str(mode or '') == 'asq':
1011
+ rows.extend(agent.inspect_qualitative_study_fact_contract(text, data_metadata, section_name=section_name) or [])
1012
+ elif str(mode or '') == 'amj':
1013
+ rows.extend(quant_engine.inspect_quantitative_study_fact_contract(text, data_metadata, section_name=section_name) or [])
1014
+ scope_inspector = getattr(agent, "inspect_study_design_scope_contract", None)
1015
+ if callable(scope_inspector):
1016
+ rows.extend(scope_inspector(text, data_metadata, section_name=section_name) or [])
1017
+ return list(rows)
1018
 
1019
  @staticmethod
1020
  def _section_retry_feedback(violations, *, limit=8) -> str:
 
1080
  "hypothesis_contract_missing_id", "hypothesis_contract_ordinal_alias",
1081
  "hypothesis_contract_pairing_mismatch", "hypothesis_contract_unknown_id",
1082
  "citation_outside_frozen_section_whitelist", "authorial_ai_meta_trace",
1083
+ "study_design_scope_mismatch", "unsupported_focal_construct", "unsupported_relation_claim",
1084
+ "unsupported_moderation_claim", "unsupported_mediation_claim", "unsupported_temporal_scope",
1085
+ "unsupported_design_tradition", "unsupported_unit_of_analysis", "unsupported_data_source_claim",
1086
+ "unsupported_process_claim",
1087
  }
1088
  if kind in always_fatal:
1089
  return "FATAL"
 
1127
  "hypothesis": {"hypothesis_contract_missing_id", "hypothesis_contract_ordinal_alias",
1128
  "hypothesis_contract_pairing_mismatch", "hypothesis_contract_unknown_id"},
1129
  "workflow_trace": {"authorial_ai_meta_trace", "author_facing_internal_workflow_language"},
1130
+ "study_design_scope": {
1131
+ "study_design_scope_mismatch", "unsupported_focal_construct", "unsupported_relation_claim",
1132
+ "unsupported_moderation_claim", "unsupported_mediation_claim", "unsupported_temporal_scope",
1133
+ "unsupported_design_tradition", "unsupported_unit_of_analysis", "unsupported_data_source_claim",
1134
+ "unsupported_process_claim",
1135
+ },
1136
  }
1137
  for lineage, kinds in groups.items():
1138
  if kind in kinds:
 
1456
  hid = str(item.get("hypothesis_id") or "").strip()
1457
  predictor = str(item.get("predictor") or "").strip()
1458
  outcome = str(item.get("outcome") or "").strip()
1459
+ moderator = str(item.get("moderator") or "").strip()
1460
+ relation = str(item.get("relation") or "association").strip().casefold()
1461
  if hid and predictor and outcome:
1462
+ if relation == "moderation" and moderator:
1463
+ lines.append(f"{hid}: {moderator} moderates the association between {predictor} and {outcome}.")
1464
+ else:
1465
+ lines.append(f"{hid}: {predictor} is associated with {outcome}.")
1466
  if not lines:
1467
  return ""
1468
  indirect = dict(record.get("indirect_analysis") or {})
 
1493
  hid = str(item.get("hypothesis_id") or "").strip().upper()
1494
  predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
1495
  outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
1496
+ moderator = str(item.get("moderator") or "").strip()
1497
+ relation = str(item.get("relation") or "association").strip().casefold()
1498
  if hid and predictor and outcome:
1499
+ if relation == "moderation" and moderator:
1500
+ lines.append(f"{hid}: {moderator} moderates the association between {predictor} and {outcome}.")
1501
+ else:
1502
+ lines.append(f"{hid}: {predictor} is associated with {outcome}.")
1503
  return "\n".join(lines)
1504
 
1505
  @staticmethod
 
1554
  })
1555
  all_constructs = {
1556
  re.sub(r"\s+", " ", str(item.get(key) or "")).strip()
1557
+ for item in items for key in ("predictor", "outcome", "moderator")
1558
  if str(item.get(key) or "").strip()
1559
  }
1560
  for start, end, sentence in SSciPipelineAgent._sentence_spans(value):
 
1571
  continue
1572
  predictor = re.sub(r"\s+", " ", str(item.get("predictor") or "")).strip()
1573
  outcome = re.sub(r"\s+", " ", str(item.get("outcome") or "")).strip()
1574
+ moderator = re.sub(r"\s+", " ", str(item.get("moderator") or "")).strip()
1575
  lower_sentence = re.sub(r"\s+", " ", sentence).casefold()
1576
  mentioned_constructs = {
1577
  term for term in all_constructs if term.casefold() in lower_sentence
 
1579
  # Pairing identity is global, not limited to the theory-definition
1580
  # sections. Require at least two named constructs so a bare reference
1581
  # such as "H1 was supported" cannot be misclassified as remapping.
1582
+ expected_terms = [term for term in (predictor, outcome, moderator) if term]
1583
+ if len(mentioned_constructs) >= 2 and predictor and outcome and any(
1584
+ term.casefold() not in lower_sentence for term in expected_terms
1585
  ):
1586
  rows.append({
1587
  "type": "hypothesis_contract_pairing_mismatch",
 
3219
  hypothesis_ledger = dict(data_metadata.get("hypothesis_ledger") or {})
3220
  expected_hypothesis_version = str(getattr(quant_engine, "HYPOTHESIS_LEDGER_VERSION", ""))
3221
  if hypothesis_ledger.get("version") != expected_hypothesis_version:
3222
+ study_contract = dict(data_metadata.get("study_design_contract") or {})
3223
+ if study_contract:
3224
+ rebuilt_hypotheses = quant_engine.build_hypothesis_ledger(study_contract)
3225
+ else:
3226
+ constructs = dict(data_metadata.get("constructs") or {})
3227
+ rebuilt_hypotheses = quant_engine.build_hypothesis_ledger([
3228
+ constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")
3229
+ ])
3230
  if not rebuilt_hypotheses:
3231
  raise RuntimeError("quantitative_hypothesis_ledger_rebuild_failed")
3232
  data_metadata["hypothesis_ledger"] = rebuilt_hypotheses
 
3237
  front_contract = dict(data_metadata.get("front_matter_contract") or {})
3238
  expected_front_version = str(getattr(quant_engine, "FRONT_MATTER_CONTRACT_VERSION", ""))
3239
  if front_contract.get("version") != expected_front_version:
3240
+ study_contract = dict(data_metadata.get("study_design_contract") or {})
3241
+ if study_contract:
3242
+ rebuilt_front = quant_engine.build_front_matter_contract(topic, keywords, study_contract)
3243
+ else:
3244
+ constructs = dict(data_metadata.get("constructs") or {})
3245
+ rebuilt_front = quant_engine.build_front_matter_contract(
3246
+ topic, keywords, [
3247
+ constructs.get("predictor"), constructs.get("mediator"), constructs.get("outcome")
3248
+ ]
3249
+ )
3250
  if rebuilt_front:
3251
  data_metadata["front_matter_contract"] = rebuilt_front
3252
  else:
 
4227
  table_rule = (
4228
  "AUTHORITATIVE TABLE CONTRACT: Do not create, reproduce, renumber, or alter any Markdown statistical table. "
4229
  "Required statistical tables are handled separately from narrative prose. "
4230
+ f"You may discuss and refer only to the frozen table numbers {[int(row.get('number') or 0) for row in data_metadata.get('quantitative_table_manifest', {}).get('tables', []) if int(row.get('number') or 0) > 0]}; do not invent an additional table or change a table number."
4231
  )
4232
  elif sec["is_method_or_result"] and mode == "amj":
4233
  table_rule = "Present only study-supported statistical evidence; do not invent tables or numerical results."
core/pipeline_agent.py CHANGED
@@ -28,6 +28,7 @@ except ImportError:
28
 
29
  from config import APP_CONFIG
30
  from core.production_guard import find_authorial_ai_meta_spans
 
31
  from core.model_gateway import ProviderNeutralModelGateway
32
  from core.model_usage import ModelUsageEvent, emit_usage_event, normalize_deepseek_usage, prompt_identity, sanitize_metadata
33
 
@@ -1761,6 +1762,30 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1761
  )
1762
  raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
1763
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1764
  @staticmethod
1765
  def _quantitative_front_matter_fallback(contract):
1766
  """Last-resort degraded front matter derived only from frozen authority.
@@ -1769,10 +1794,8 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1769
  semantic reconciliation cannot produce a validated result; it never
1770
  replaces the normal LLM path.
1771
  """
1772
- constructs = dict((contract or {}).get("constructs") or {})
1773
- roles = list((contract or {}).get("construct_roles") or ("predictor", "mediator", "outcome"))
1774
- values = [re.sub(r"\s+", " ", str(constructs.get(role) or "")).strip() for role in roles]
1775
- values = [item for item in values if item]
1776
  if not values:
1777
  return {"title": "Quantitative Management Study", "keywords": ["quantitative research", "management research", "empirical study", "evidence"]}
1778
  title = ", ".join(values[:-1]) + ((", and " if len(values) > 2 else " and ") + values[-1] if len(values) > 1 else "")
@@ -1802,14 +1825,13 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1802
  issues.append("keywords_count_must_be_4_to_6")
1803
  if len({item.casefold() for item in keywords}) != len(keywords):
1804
  issues.append("keywords_must_be_distinct")
1805
- constructs = dict((contract or {}).get("constructs") or {})
1806
- roles = list((contract or {}).get("construct_roles") or constructs.keys())
1807
  combined = (title + " ; " + " ; ".join(keywords)).casefold()
1808
  missing = [
1809
- str(constructs.get(role) or "").strip()
1810
- for role in roles
1811
- if str(constructs.get(role) or "").strip()
1812
- and str(constructs.get(role) or "").strip().casefold() not in combined
1813
  ]
1814
  if missing:
1815
  issues.append("missing_frozen_constructs:" + " | ".join(missing))
@@ -1825,9 +1847,7 @@ Return a concise PROVISIONAL RESEARCH LOGIC CONTRACT in plain English prose. It
1825
  degraded-mode fallback keeps packaging recoverable.
1826
  """
1827
  contract = dict(contract or {})
1828
- constructs = dict(contract.get("constructs") or {})
1829
- roles = list(contract.get("construct_roles") or constructs.keys())
1830
- frozen = [{"role": role, "name": constructs.get(role)} for role in roles if constructs.get(role)]
1831
  if not frozen:
1832
  logging.warning("Front-matter semantic contract missing frozen constructs; using degraded fallback.")
1833
  return self._quantitative_front_matter_fallback(contract)
 
28
 
29
  from config import APP_CONFIG
30
  from core.production_guard import find_authorial_ai_meta_spans
31
+ from core.study_design_contract import inspect_study_design_scope
32
  from core.model_gateway import ProviderNeutralModelGateway
33
  from core.model_usage import ModelUsageEvent, emit_usage_event, normalize_deepseek_usage, prompt_identity, sanitize_metadata
34
 
 
1762
  )
1763
  raise DeepSeekRequestError(f'{phase} front matter validation failed: {last_error}')
1764
 
1765
+ @staticmethod
1766
+ def _quantitative_front_matter_items(contract):
1767
+ """Return extensible frozen construct rows, with legacy role-map fallback."""
1768
+ record = dict(contract or {})
1769
+ items = [dict(item) for item in (record.get("construct_items") or []) if isinstance(item, dict)]
1770
+ if items:
1771
+ return [
1772
+ {"role": "/".join(item.get("roles") or []), "name": re.sub(r"\s+", " ", str(item.get("label") or "")).strip()}
1773
+ for item in items if str(item.get("label") or "").strip()
1774
+ ]
1775
+ constructs = dict(record.get("constructs") or {})
1776
+ roles = list(record.get("construct_roles") or constructs.keys())
1777
+ return [
1778
+ {"role": role, "name": re.sub(r"\s+", " ", str(constructs.get(role) or "")).strip()}
1779
+ for role in roles if str(constructs.get(role) or "").strip()
1780
+ ]
1781
+
1782
+ @staticmethod
1783
+ def inspect_study_design_scope_contract(text, metadata, section_name=""):
1784
+ contract = dict((metadata or {}).get("study_design_contract") or {})
1785
+ if not contract:
1786
+ return []
1787
+ return inspect_study_design_scope(text, contract, section_name=section_name)
1788
+
1789
  @staticmethod
1790
  def _quantitative_front_matter_fallback(contract):
1791
  """Last-resort degraded front matter derived only from frozen authority.
 
1794
  semantic reconciliation cannot produce a validated result; it never
1795
  replaces the normal LLM path.
1796
  """
1797
+ items = SSciPipelineAgent._quantitative_front_matter_items(contract)
1798
+ values = [row.get("name") for row in items if row.get("name")]
 
 
1799
  if not values:
1800
  return {"title": "Quantitative Management Study", "keywords": ["quantitative research", "management research", "empirical study", "evidence"]}
1801
  title = ", ".join(values[:-1]) + ((", and " if len(values) > 2 else " and ") + values[-1] if len(values) > 1 else "")
 
1825
  issues.append("keywords_count_must_be_4_to_6")
1826
  if len({item.casefold() for item in keywords}) != len(keywords):
1827
  issues.append("keywords_must_be_distinct")
1828
+ frozen_items = SSciPipelineAgent._quantitative_front_matter_items(contract)
 
1829
  combined = (title + " ; " + " ; ".join(keywords)).casefold()
1830
  missing = [
1831
+ str(row.get("name") or "").strip()
1832
+ for row in frozen_items
1833
+ if str(row.get("name") or "").strip()
1834
+ and str(row.get("name") or "").strip().casefold() not in combined
1835
  ]
1836
  if missing:
1837
  issues.append("missing_frozen_constructs:" + " | ".join(missing))
 
1847
  degraded-mode fallback keeps packaging recoverable.
1848
  """
1849
  contract = dict(contract or {})
1850
+ frozen = self._quantitative_front_matter_items(contract)
 
 
1851
  if not frozen:
1852
  logging.warning("Front-matter semantic contract missing frozen constructs; using degraded fallback.")
1853
  return self._quantitative_front_matter_fallback(contract)
core/qualitative_engine.py CHANGED
@@ -9,12 +9,14 @@ import logging
9
  import re
10
 
11
  from config import APP_CONFIG
 
12
 
13
 
14
  class QualitativeEngine:
15
  QUALITATIVE_APPENDIX_MANIFEST_VERSION = "qualitative-appendix-manifest-v1"
16
  TARGET_APPENDIX_BINDING_VERSION = "target-qualitative-appendix-binding-v2"
17
  SEMANTIC_CLAIM_FRAME_VERSION = "smartpaper-core-semantic-claim-frame-v1"
 
18
  APPENDIX_FIELDS = ("theme", "concept", "stage", "case_label", "evidence_role")
19
  APPENDIX_CANONICAL_LABEL_FIELDS = ("theme", "concept")
20
  APPENDIX_HEADERS = (
@@ -982,6 +984,7 @@ Rules:
982
  "evidence": clean_evidence,
983
  "themes": themes,
984
  }
 
985
  metadata["semantic_claim_frame"] = self.build_qualitative_semantic_claim_frame(metadata)
986
  status_message = "✅ 定性研究事实、分析设计与证据结构已冻结。"
987
  return md, status_message, metadata
 
9
  import re
10
 
11
  from config import APP_CONFIG
12
+ from core.study_design_contract import STUDY_DESIGN_CONTRACT_VERSION, build_qualitative_contract
13
 
14
 
15
  class QualitativeEngine:
16
  QUALITATIVE_APPENDIX_MANIFEST_VERSION = "qualitative-appendix-manifest-v1"
17
  TARGET_APPENDIX_BINDING_VERSION = "target-qualitative-appendix-binding-v2"
18
  SEMANTIC_CLAIM_FRAME_VERSION = "smartpaper-core-semantic-claim-frame-v1"
19
+ STUDY_DESIGN_CONTRACT_VERSION = STUDY_DESIGN_CONTRACT_VERSION
20
  APPENDIX_FIELDS = ("theme", "concept", "stage", "case_label", "evidence_role")
21
  APPENDIX_CANONICAL_LABEL_FIELDS = ("theme", "concept")
22
  APPENDIX_HEADERS = (
 
984
  "evidence": clean_evidence,
985
  "themes": themes,
986
  }
987
+ metadata["study_design_contract"] = build_qualitative_contract(topic, keywords, metadata)
988
  metadata["semantic_claim_frame"] = self.build_qualitative_semantic_claim_frame(metadata)
989
  status_message = "✅ 定性研究事实、分析设计与证据结构已冻结。"
990
  return md, status_message, metadata
core/quantitative_engine.py CHANGED
@@ -13,6 +13,13 @@ import pandas as pd
13
 
14
  from i18n import tr
15
  from utils.file_parser import extract_data_from_files
 
 
 
 
 
 
 
16
 
17
 
18
  class QuantitativeEngine:
@@ -24,6 +31,7 @@ class QuantitativeEngine:
24
  FRONT_MATTER_CONTRACT_VERSION = "quantitative-front-matter-contract-v2"
25
  TARGET_TABLE_BINDING_VERSION = "target-quantitative-table-binding-v1"
26
  SEMANTIC_CLAIM_FRAME_VERSION = "smartpaper-core-semantic-claim-frame-v1"
 
27
 
28
  _TARGET_TABLE_LOCALIZATION = {
29
  "zh": {
@@ -111,70 +119,34 @@ class QuantitativeEngine:
111
 
112
  @classmethod
113
  def build_front_matter_contract(cls, topic, keywords, constructs):
114
- """Freeze semantic authority without hard-coding reader-facing prose.
115
 
116
- The evidence engine owns construct identities/roles. The user's topic
117
- and keyword seeds remain available for retrieval, literature synthesis,
118
- and domain reasoning. Reader-facing title/keywords are reconciled later
119
- by the model against this frozen contract and then deterministically
120
- validated.
121
  """
122
- role_names = ("predictor", "mediator", "outcome")
 
 
 
123
  if isinstance(constructs, dict):
124
- role_map = {
125
- role: re.sub(r"\s+", " ", str(constructs.get(role) or "")).strip()
126
- for role in role_names
127
- }
128
  else:
129
- values = [re.sub(r"\s+", " ", str(item or "")).strip() for item in (constructs or [])]
130
- if len(values) != len(role_names):
131
- return {}
132
- role_map = dict(zip(role_names, values))
133
- if any(not role_map.get(role) for role in role_names):
134
- return {}
135
- source_keywords = [
136
- re.sub(r"\s+", " ", item).strip()
137
- for item in re.split(r"[,;\n|]+", str(keywords or ""))
138
- if re.sub(r"\s+", " ", item).strip()
139
- ]
140
- return {
141
- "version": cls.FRONT_MATTER_CONTRACT_VERSION,
142
- "policy": "system_generated_frozen_construct_semantic_authority",
143
- "source_topic": re.sub(r"\s+", " ", str(topic or "")).strip(),
144
- "source_keywords": source_keywords,
145
- "construct_roles": list(role_names),
146
- "constructs": role_map,
147
- }
148
 
149
  @classmethod
150
  def build_hypothesis_ledger(cls, constructs):
151
- """Freeze one result-independent AMJ hypothesis identity contract.
152
-
153
- The system-generated quantitative design is observational and does not
154
- justify result-driven directional hypotheses. The three numbered
155
- hypotheses therefore freeze only the construct pairing/association
156
- identity. The indirect association is an analysis target, not H4.
157
- """
158
- values = [re.sub(r"\s+", " ", str(item or "")).strip() for item in (constructs or [])]
159
- if len(values) != 3 or any(not item for item in values):
160
- return {}
161
- predictor, mediator, outcome = values
162
- items = [
163
- {"hypothesis_id": "H1", "predictor": predictor, "outcome": outcome, "relation": "association"},
164
- {"hypothesis_id": "H2", "predictor": predictor, "outcome": mediator, "relation": "association"},
165
- {"hypothesis_id": "H3", "predictor": mediator, "outcome": outcome, "relation": "association"},
166
- ]
167
- return {
168
- "version": cls.HYPOTHESIS_LEDGER_VERSION,
169
- "items": items,
170
- "indirect_analysis": {
171
- "predictor": predictor,
172
- "mediator": mediator,
173
- "outcome": outcome,
174
- "numbered_hypothesis": False,
175
- "relation": "estimated_indirect_association",
176
- },
177
- }
178
 
179
  @classmethod
180
  def build_table_manifest_from_evidence_markdown(cls, evidence_markdown):
@@ -478,42 +450,72 @@ class QuantitativeEngine:
478
 
479
  @staticmethod
480
  def _safe_constructs(values):
 
481
  cleaned = []
482
  for value in values or []:
483
  name = re.sub(r"\s+", " ", str(value or "")).strip(" .,:;-_\n\t")
484
  if not name or len(name) > 80 or name.lower() in {item.lower() for item in cleaned}:
485
  continue
486
  cleaned.append(name)
487
- return cleaned[:3]
 
 
 
 
 
 
 
 
488
 
489
  def _derive_system_design(self, agent, topic="", keywords=""):
490
- """Freeze a parsimonious topic-aligned design without inventing named sites."""
491
- fallback = {
 
 
 
 
492
  "constructs": [
493
- "Focal Predictor",
494
- "Focal Mediator",
495
- "Focal Outcome",
 
 
 
 
 
496
  ],
497
- "research_context": "respondents with experience relevant to the focal management phenomenon",
 
 
498
  }
499
  if agent is None:
500
- return fallback
501
- prompt = f"""Design the minimum coherent quantitative management-study skeleton for the topic below.
502
- Return ONLY valid JSON:
503
- {{"constructs":["predictor construct","mediating construct","outcome construct"],"research_context":"one generic respondent context"}}
 
 
 
 
 
 
504
  Rules:
505
- 1. Exactly three distinct constructs in predictor -> mediator -> outcome order.
506
- 2. Use concise construct labels that can appear throughout a journal article.
507
- 3. The context must be generic and directly relevant to the topic. Do NOT name or invent a country, university, firm, industry sample frame, software package, time period, or institution.
508
- 4. This is a cross-sectional single-wave survey design, so do not imply experimental or causal identification.
509
- 5. Do not create any number; sample size and scale are frozen elsewhere.
 
 
 
 
510
  Topic: {topic}
511
  Keywords: {keywords}"""
512
  try:
513
  raw = agent.call_deepseek(
514
  "You are a rigorous management research-design editor.",
515
  prompt,
516
- max_tokens=500,
517
  temp=0.0,
518
  model_role="fast",
519
  thinking=False,
@@ -521,23 +523,25 @@ Keywords: {keywords}"""
521
  logical_node='structured_json',
522
  )
523
  parsed = agent._extract_json_from_text(raw)
524
- if not isinstance(parsed, dict):
525
- return fallback
526
- constructs = self._safe_constructs(parsed.get("constructs"))
527
- context = re.sub(r"\s+", " ", str(parsed.get("research_context") or "")).strip()
528
- if len(constructs) != 3:
529
- return fallback
530
- if not context or len(context) > 240:
531
- context = fallback["research_context"]
532
- # Named-location/site details are deliberately not accepted from this
533
- # design step. The manuscript may remain generic when no user evidence
534
- # supplies a real setting.
535
  if re.search(r"\b(university|company|corporation|inc\.?|ltd\.?|country|china|usa|united states|europe|asia)\b", context, re.I):
536
- context = fallback["research_context"]
537
- return {"constructs": constructs, "research_context": context}
 
 
 
 
 
 
 
538
  except Exception as exc:
539
- logging.warning("Quantitative study-design freeze fell back safely: %s", exc)
540
- return fallback
541
 
542
  def generate_empirical_data_robust(
543
  self,
@@ -712,6 +716,220 @@ Keywords: {keywords}"""
712
  return summary, path_table, spearman
713
 
714
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
  @staticmethod
716
  def _sentence_spans(text):
717
  """Return sentence spans without splitting decimal statistics or citations."""
@@ -875,7 +1093,17 @@ Keywords: {keywords}"""
875
  }
876
 
877
  constructs = dict(meta.get("constructs") or {}) if isinstance(meta.get("constructs"), dict) else {}
 
878
  results = dict(meta.get("association_results") or {}) if isinstance(meta.get("association_results"), dict) else {}
 
 
 
 
 
 
 
 
 
879
  return {
880
  "version": cls.QUANTITATIVE_EVIDENCE_LEDGER_VERSION,
881
  "source_type": source_type,
@@ -883,15 +1111,13 @@ Keywords: {keywords}"""
883
  "sample_size": int(sample_size) if isinstance(sample_size, (int, np.integer)) or str(sample_size or "").isdigit() else None,
884
  "scale_points": int(scale_points) if isinstance(scale_points, (int, np.integer)) or str(scale_points or "").isdigit() else None,
885
  "study_design": str(meta.get("study_design") or "").strip(),
 
886
  "constructs": {k: str(v or "").strip() for k, v in constructs.items() if str(v or "").strip()},
 
887
  "association_results": results,
888
  "uploaded_metric_anchors": uploaded_metric_anchors,
889
  "causal_identification": "none" if strict else "evidence_owned",
890
- "allowed_methods": [
891
- "reliability", "composite reliability", "average variance extracted",
892
- "Fornell-Larcker", "standardized association", "bootstrap",
893
- "Spearman", "VIF", "descriptive statistics",
894
- ] if strict else [],
895
  "forbidden_unowned_methods": [
896
  "confirmatory factor analysis", "CFA", "structural equation modeling", "SEM",
897
  "PLS-SEM", "RMSEA", "CFI", "TLI", "SRMR", "Harman",
@@ -1601,12 +1827,44 @@ Keywords: {keywords}"""
1601
 
1602
  @classmethod
1603
  def _strict_standardized_metric_occurrences(cls, sentence, constructs, results):
1604
- """Yield clause-bound owners for each beta/standardized-estimate occurrence."""
1605
  regex = re.compile(r"(?i)((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)")
1606
- expected = {key: results.get(key) for key in ("a", "b", "direct", "indirect")}
 
 
1607
  for match in regex.finditer(str(sentence or "")):
1608
  owner, clause = cls._quantitative_metric_owner_for_occurrence(sentence, match, constructs)
1609
- yield match, owner, expected.get(owner) if owner else None, clause
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1610
 
1611
  @classmethod
1612
  def inspect_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
@@ -1715,7 +1973,7 @@ Keywords: {keywords}"""
1715
  "preview":sentence.strip()[:180],
1716
  })
1717
 
1718
- vif_expected = results.get("predictor_mediator_vif") if strict else uploaded.get("VIF")
1719
  boot_expected = results.get("bootstrap_iterations") if strict else uploaded.get("bootstrap_iterations")
1720
  add_metric_violations(sentence, r"(\bVIF\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", vif_expected, label="VIF")
1721
  add_metric_violations(sentence, r"(\bbootstrap\s+(?:resamples?|iterations?)\s*(?:=|:)?\s*)(\d{2,6})", boot_expected, dtype="int", label="bootstrap_iterations")
@@ -1724,6 +1982,9 @@ Keywords: {keywords}"""
1724
  construct = str(constructs.get(role) or "")
1725
  if construct and results.get(key) is not None:
1726
  add_metric_violations(sentence, rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", results.get(key), label=key)
 
 
 
1727
  # R6-R14 clause-bound metric ownership: validate each occurrence
1728
  # against its own local construct relation, never one owner per sentence.
1729
  for metric_match, metric_owner, metric_expected, metric_clause in cls._strict_standardized_metric_occurrences(
@@ -1891,7 +2152,7 @@ Keywords: {keywords}"""
1891
  return match.group(1) + expected_text
1892
  revised = regex.sub(repl, revised)
1893
 
1894
- vif_expected = results.get("predictor_mediator_vif") if strict else uploaded.get("VIF")
1895
  boot_expected = results.get("bootstrap_iterations") if strict else uploaded.get("bootstrap_iterations")
1896
  replace_local_metric(r"(\bVIF\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", vif_expected, label="VIF")
1897
  replace_local_metric(r"(\bbootstrap\s+(?:resamples?|iterations?)\s*(?:=|:)?\s*)(\d{2,6})", boot_expected, dtype="int", label="bootstrap_iterations")
@@ -1902,6 +2163,10 @@ Keywords: {keywords}"""
1902
  if construct and results.get(key) is not None:
1903
  metric_pattern = rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)"
1904
  replace_local_metric(metric_pattern, results.get(key), label=key)
 
 
 
 
1905
 
1906
  # R6-R14 deterministic repair is also occurrence-local. Never
1907
  # rewrite every metric in a sentence to one selected path value.
@@ -1969,18 +2234,14 @@ Keywords: {keywords}"""
1969
  if user_md:
1970
  evidence_sections = [
1971
  heading.strip()
1972
- for heading in re.findall(
1973
- r"(?m)^#{1,3}\s+(.+?)\s*$",
1974
- user_md,
1975
- )
1976
  if heading.strip()
1977
  ]
1978
  metadata = {
1979
  "mode": "quantitative",
1980
  "source_type": "user_provided",
1981
  "disclosure_required": False,
1982
- "evidence_sections": evidence_sections[:20]
1983
- or ["User-provided quantitative evidence"],
1984
  "evidence_boundary": (
1985
  "Only values explicitly present in the uploaded evidence are frozen. "
1986
  "Do not infer absent setting, sample, measures, software, or results."
@@ -1989,13 +2250,58 @@ Keywords: {keywords}"""
1989
  metadata["quantitative_evidence_ledger"] = self.build_quantitative_evidence_ledger(metadata, user_md)
1990
  return None, user_md, tr(language, "actual_stats_parsed"), metadata
1991
 
1992
- design = self._derive_system_design(agent, topic, keywords)
1993
- construct_names = design["constructs"]
1994
- df, validity_df, fl_matrix_df, mean_scores, corr_matrix = self.generate_empirical_data_robust(
 
 
 
 
 
 
 
 
1995
  construct_names=construct_names
1996
  )
1997
- path_summary, path_table, spearman = self._path_evidence(mean_scores, construct_names)
1998
- context = design["research_context"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1999
  generated_prompt = f"""### Frozen Quantitative Study Design
2000
  | Field | Frozen value |
2001
  | :--- | :--- |
@@ -2003,10 +2309,18 @@ Keywords: {keywords}"""
2003
  | Respondent context | {context} |
2004
  | Sample size | {len(df)} |
2005
  | Response scale | 7-point Likert-type scale |
2006
- | Predictor | {construct_names[0]} |
2007
- | Mediator | {construct_names[1]} |
2008
- | Outcome | {construct_names[2]} |
2009
- | Causal identification | None; report associations and estimated indirect association, not causal effects |
 
 
 
 
 
 
 
 
2010
 
2011
  ### Table 1: Reliability and Convergent Validity
2012
  {validity_df.to_markdown(index=False)}
@@ -2021,20 +2335,25 @@ Keywords: {keywords}"""
2021
  ### Table 4: Rank-Order Robustness Correlations (Spearman)
2022
  {spearman.round(3).to_markdown()}
2023
 
2024
- ### Frozen Bootstrap and Collinearity Checks
2025
- - Bootstrap resamples: {path_summary['bootstrap_iterations']}.
2026
- - Indirect association ({construct_names[0]} → {construct_names[1]} → {construct_names[2]}): {path_summary['indirect']}; 95% bootstrap CI [{path_summary['indirect_ci'][0]}, {path_summary['indirect_ci'][1]}].
2027
- - Residual direct association ({construct_names[0]} → {construct_names[2]} controlling {construct_names[1]}): {path_summary['direct']}; 95% bootstrap CI [{path_summary['direct_ci'][0]}, {path_summary['direct_ci'][1]}].
2028
- - Predictor/mediator VIF in the outcome model: {path_summary['predictor_mediator_vif']}.
2029
 
2030
  [STRICT EMPIRICAL INSTRUCTION]
2031
  1. ZERO HALLUCINATION: Every study-specific fact and number must come from this frozen packet. Never invent a country, named organization, sampling frame, response rate, demographic characteristic, measure source, software package, coefficient, p-value, fit index, robustness test, or date.
2032
- 2. DESIGN DISCIPLINE: This is a cross-sectional observational survey. Use association language. The indirect estimate is not causal mediation identification.
2033
- 3. MEASUREMENT DEFENSE: State reliability/convergent/discriminant validity only when the displayed values support it.
2034
- 4. RESULTS DISCIPLINE: Results must report this study's frozen numbers without attaching external citations to the coefficients or sample facts.
2035
- 5. ROBUSTNESS: The Spearman matrix, bootstrap intervals, and VIF above are the only frozen robustness evidence. Do not add another robustness procedure.
2036
- 6. CONSISTENCY: The predictor, mediator, outcome, sample size, context, scale, and all numerical results above are the single source of quantitative truth throughout the manuscript.
 
2037
  """
 
 
 
 
 
 
 
2038
  metadata = {
2039
  "mode": "quantitative",
2040
  "source_type": "system_generated",
@@ -2043,19 +2362,19 @@ Keywords: {keywords}"""
2043
  "scale_points": 7,
2044
  "study_design": "cross-sectional single-wave questionnaire",
2045
  "research_context": context,
2046
- "constructs": {
2047
- "predictor": construct_names[0],
2048
- "mediator": construct_names[1],
2049
- "outcome": construct_names[2],
2050
- },
2051
  "association_results": path_summary,
2052
  "method": (
2053
- "Positive-semidefinite latent correlation generation, indicator loadings, "
2054
- "Likert discretization, reliability/validity summaries, standardized association "
2055
- "models, fixed-seed bootstrap intervals, and Spearman robustness correlations."
2056
  ),
2057
  "generation_parameters": {
2058
  "constructs": construct_names,
 
 
2059
  "sample_size": int(len(df)),
2060
  "likert_scale_points": 7,
2061
  "items_per_construct": "random integer from 3 to 5",
@@ -2063,45 +2382,28 @@ Keywords: {keywords}"""
2063
  "latent_correlation_control": "positive-semidefinite matrix with maximum target correlation 0.55",
2064
  "item_generation": "loading × latent score + normally distributed measurement error",
2065
  "discretization": "round to nearest integer and clamp to 1–7",
2066
- "bootstrap_iterations": path_summary["bootstrap_iterations"],
2067
  "bootstrap_seed": 20260809,
2068
  "reported_summaries": [
2069
- "Cronbach alpha",
2070
- "composite reliability",
2071
- "average variance extracted",
2072
- "descriptive statistics",
2073
- "Fornell-Larcker matrix",
2074
- "standardized association estimates",
2075
- "R-squared",
2076
- "bootstrap confidence intervals",
2077
- "Spearman correlations",
2078
- "VIF",
2079
  ],
2080
  },
2081
  "evidence_sections": [
2082
- "Frozen quantitative study design",
2083
- "Reliability and convergent validity",
2084
- "Descriptive statistics and discriminant validity",
2085
- "Frozen association model results",
2086
- "Rank-order robustness correlations",
2087
- "Frozen bootstrap and collinearity checks",
2088
  ],
2089
  }
2090
  metadata["semantic_claim_frame_version"] = self.SEMANTIC_CLAIM_FRAME_VERSION
2091
- metadata["front_matter_contract"] = self.build_front_matter_contract(
2092
- topic, keywords, metadata.get("constructs") or construct_names
2093
- )
2094
- metadata["hypothesis_ledger"] = self.build_hypothesis_ledger(construct_names)
2095
- metadata["quantitative_table_manifest"] = self.build_table_manifest(
2096
- validity_df, fl_matrix_df, path_table, spearman
2097
- )
2098
  metadata["quantitative_evidence_ledger"] = self.build_quantitative_evidence_ledger(metadata, generated_prompt)
2099
- return (
2100
- df,
2101
- generated_prompt,
2102
- tr(language, "synthetic_stats_generated"),
2103
- metadata,
2104
- )
2105
 
2106
 
2107
  def get_quantitative_engine():
 
13
 
14
  from i18n import tr
15
  from utils.file_parser import extract_data_from_files
16
+ from core.study_design_contract import (
17
+ STUDY_DESIGN_CONTRACT_VERSION,
18
+ build_quantitative_contract,
19
+ validate_quantitative_contract,
20
+ compile_quantitative_hypothesis_ledger,
21
+ build_quantitative_front_matter_contract,
22
+ )
23
 
24
 
25
  class QuantitativeEngine:
 
31
  FRONT_MATTER_CONTRACT_VERSION = "quantitative-front-matter-contract-v2"
32
  TARGET_TABLE_BINDING_VERSION = "target-quantitative-table-binding-v1"
33
  SEMANTIC_CLAIM_FRAME_VERSION = "smartpaper-core-semantic-claim-frame-v1"
34
+ STUDY_DESIGN_CONTRACT_VERSION = STUDY_DESIGN_CONTRACT_VERSION
35
 
36
  _TARGET_TABLE_LOCALIZATION = {
37
  "zh": {
 
119
 
120
  @classmethod
121
  def build_front_matter_contract(cls, topic, keywords, constructs):
122
+ """Build a reader-front-matter semantic contract from adaptive design authority.
123
 
124
+ ``constructs`` accepts either the new Study Design Contract or the legacy
125
+ three-construct list/dict so old checkpoints remain readable.
 
 
 
126
  """
127
+ if isinstance(constructs, dict) and constructs.get("version") == cls.STUDY_DESIGN_CONTRACT_VERSION:
128
+ return build_quantitative_front_matter_contract(constructs, version=cls.FRONT_MATTER_CONTRACT_VERSION)
129
+ if isinstance(constructs, dict) and "quantitative" in constructs:
130
+ return build_quantitative_front_matter_contract(constructs, version=cls.FRONT_MATTER_CONTRACT_VERSION)
131
  if isinstance(constructs, dict):
132
+ values = [constructs.get(role) for role in ("predictor", "mediator", "outcome")]
 
 
 
133
  else:
134
+ values = list(constructs or [])
135
+ candidate = {"constructs": values, "research_context": "respondents with experience relevant to the focal management phenomenon"}
136
+ contract = build_quantitative_contract(topic, keywords, candidate, source_type="system_generated")
137
+ return build_quantitative_front_matter_contract(contract, version=cls.FRONT_MATTER_CONTRACT_VERSION)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  @classmethod
140
  def build_hypothesis_ledger(cls, constructs):
141
+ """Compile H1-Hn from a frozen adaptive graph; preserve legacy H1-H3 order."""
142
+ if isinstance(constructs, dict) and constructs.get("version") == cls.STUDY_DESIGN_CONTRACT_VERSION:
143
+ contract = constructs
144
+ elif isinstance(constructs, dict) and "quantitative" in constructs:
145
+ contract = constructs
146
+ else:
147
+ values = list(constructs.values()) if isinstance(constructs, dict) else list(constructs or [])
148
+ contract = build_quantitative_contract("", "", {"constructs": values}, source_type="system_generated")
149
+ return compile_quantitative_hypothesis_ledger(contract, version=cls.HYPOTHESIS_LEDGER_VERSION)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
  @classmethod
152
  def build_table_manifest_from_evidence_markdown(cls, evidence_markdown):
 
450
 
451
  @staticmethod
452
  def _safe_constructs(values):
453
+ """Legacy helper retained for tests/checkpoints; no longer defines model topology."""
454
  cleaned = []
455
  for value in values or []:
456
  name = re.sub(r"\s+", " ", str(value or "")).strip(" .,:;-_\n\t")
457
  if not name or len(name) > 80 or name.lower() in {item.lower() for item in cleaned}:
458
  continue
459
  cleaned.append(name)
460
+ return cleaned[:8]
461
+
462
+ @staticmethod
463
+ def _contract_construct_labels(contract):
464
+ return [
465
+ str(item.get("label") or "").strip()
466
+ for item in dict(contract or {}).get("quantitative", {}).get("constructs", [])
467
+ if isinstance(item, dict) and str(item.get("label") or "").strip()
468
+ ]
469
 
470
  def _derive_system_design(self, agent, topic="", keywords=""):
471
+ """Interpret a parsimonious topic-aligned quantitative graph adaptively.
472
+
473
+ The existing ``structured_json`` logical node is intentionally reused so
474
+ Standard/Flash and Flagship/Pro ModelPolicy routing remains unchanged.
475
+ """
476
+ fallback_candidate = {
477
  "constructs": [
478
+ {"label":"Focal Predictor","roles":["predictor"]},
479
+ {"label":"Focal Mediator","roles":["mediator"]},
480
+ {"label":"Focal Outcome","roles":["outcome"]},
481
+ ],
482
+ "relations": [
483
+ {"type":"direct_association","source":"Focal Predictor","target":"Focal Outcome","hypothesis_order":1},
484
+ {"type":"direct_association","source":"Focal Predictor","target":"Focal Mediator","hypothesis_order":2},
485
+ {"type":"direct_association","source":"Focal Mediator","target":"Focal Outcome","hypothesis_order":3},
486
  ],
487
+ "indirect_effects":[{"path":["Focal Predictor","Focal Mediator","Focal Outcome"],"kind":"simple"}],
488
+ "research_context":"respondents with experience relevant to the focal management phenomenon",
489
+ "background_concepts":[],
490
  }
491
  if agent is None:
492
+ return build_quantitative_contract(topic, keywords, fallback_candidate)
493
+ prompt = f"""Design the minimum coherent quantitative management-study graph for the topic below.
494
+ Return ONLY valid JSON with this schema:
495
+ {{
496
+ "constructs":[{{"label":"construct","roles":["predictor|mediator|moderator|outcome|control|covariate"]}}],
497
+ "relations":[{{"type":"direct_association|moderation|control_effect","source":"construct label","target":"construct label","moderator":"construct label or null","hypothesis_order":1}}],
498
+ "indirect_effects":[{{"path":["construct A","construct B","construct C"],"kind":"simple|serial|parallel"}}],
499
+ "research_context":"one generic respondent context",
500
+ "background_concepts":["topic concept that is contextual rather than measured/modelled"]
501
+ }}
502
  Rules:
503
+ 1. Use 2-8 distinct constructs only when the topic actually requires them; do NOT force a mediator or moderator.
504
+ 2. Preserve explicit mediation, moderation, serial/parallel mediation, multiple antecedents/outcomes, and controls when the topic clearly requests them.
505
+ 3. A moderation relation means source × moderator is associated with target.
506
+ 4. Keep the graph parsimonious and acyclic for ordinary cross-sectional regression interpretation.
507
+ 5. Use concise construct labels suitable for a journal article.
508
+ 6. Put contextual topic terms that are not measured/modelled in background_concepts instead of pretending they are focal variables.
509
+ 7. The respondent context must be generic and directly relevant. Do NOT invent a country, university, firm, named industry sample frame, software package, date, or institution.
510
+ 8. This is a cross-sectional observational survey; do not imply causal identification.
511
+ 9. Do not create any number; sample size and scale are frozen elsewhere.
512
  Topic: {topic}
513
  Keywords: {keywords}"""
514
  try:
515
  raw = agent.call_deepseek(
516
  "You are a rigorous management research-design editor.",
517
  prompt,
518
+ max_tokens=1200,
519
  temp=0.0,
520
  model_role="fast",
521
  thinking=False,
 
523
  logical_node='structured_json',
524
  )
525
  parsed = agent._extract_json_from_text(raw)
526
+ contract = build_quantitative_contract(topic, keywords, parsed if isinstance(parsed, dict) else fallback_candidate)
527
+ issues = validate_quantitative_contract(contract)
528
+ if issues:
529
+ logging.warning("Adaptive quantitative design validation fell back safely: %s", issues[:3])
530
+ return build_quantitative_contract(topic, keywords, fallback_candidate)
531
+ context = str(dict(contract.get("context") or {}).get("research_context") or "")
 
 
 
 
 
532
  if re.search(r"\b(university|company|corporation|inc\.?|ltd\.?|country|china|usa|united states|europe|asia)\b", context, re.I):
533
+ fallback_context = "respondents with experience relevant to the focal management phenomenon"
534
+ contract = dict(contract)
535
+ contract["context"] = {"research_context": fallback_context}
536
+ # Rebuild through deterministic normalizer so the hash stays authoritative.
537
+ candidate = parsed if isinstance(parsed, dict) else fallback_candidate
538
+ candidate = dict(candidate)
539
+ candidate["research_context"] = fallback_context
540
+ contract = build_quantitative_contract(topic, keywords, candidate)
541
+ return contract
542
  except Exception as exc:
543
+ logging.warning("Quantitative adaptive study-design freeze fell back safely: %s", exc)
544
+ return build_quantitative_contract(topic, keywords, fallback_candidate)
545
 
546
  def generate_empirical_data_robust(
547
  self,
 
716
  return summary, path_table, spearman
717
 
718
 
719
+ def _compile_graph_evidence(self, mean_scores, contract, bootstrap_iterations=1500):
720
+ """Compile graph-owned associations/indirect effects/moderation deterministically."""
721
+ quant = dict((contract or {}).get("quantitative") or {})
722
+ constructs = {
723
+ str(item.get("construct_id") or ""): str(item.get("label") or "").strip()
724
+ for item in (quant.get("constructs") or []) if isinstance(item, dict)
725
+ }
726
+ relations = [dict(item) for item in (quant.get("relations") or []) if isinstance(item, dict)]
727
+ if not constructs or not relations:
728
+ return {}, pd.DataFrame(), mean_scores.corr(method="spearman")
729
+
730
+ # Build one standardized OLS per target so multiple antecedents/controls are estimated together.
731
+ direct_like = [r for r in relations if r.get("type") in {"direct_association", "control_effect"}]
732
+ moderation = [r for r in relations if r.get("type") == "moderation"]
733
+ relation_results = {}
734
+ r2_by_construct = {}
735
+ model_specs = {}
736
+ for target in sorted({r.get("target") for r in relations if r.get("target")}):
737
+ predictors = []
738
+ predictor_meta = []
739
+ for r in direct_like:
740
+ if r.get("target") == target and r.get("source") in constructs:
741
+ predictors.append(mean_scores[constructs[r["source"]]].to_numpy(dtype=float))
742
+ predictor_meta.append(("relation", r))
743
+ for r in moderation:
744
+ if r.get("target") != target:
745
+ continue
746
+ sid, mid = r.get("source"), r.get("moderator")
747
+ if sid not in constructs or mid not in constructs:
748
+ continue
749
+ x = mean_scores[constructs[sid]].to_numpy(dtype=float)
750
+ m = mean_scores[constructs[mid]].to_numpy(dtype=float)
751
+ # Main effects are included when not already present in the target model.
752
+ existing_sources = {meta[1].get("source") for meta in predictor_meta if meta[0] == "relation"}
753
+ if sid not in existing_sources:
754
+ predictors.append(x); predictor_meta.append(("main", {"source": sid, "target": target, "label": constructs[sid]}))
755
+ existing_sources.add(sid)
756
+ if mid not in existing_sources:
757
+ predictors.append(m); predictor_meta.append(("main", {"source": mid, "target": target, "label": constructs[mid]}))
758
+ existing_sources.add(mid)
759
+ xz = (x - x.mean()) / (x.std(ddof=0) or 1.0)
760
+ mz = (m - m.mean()) / (m.std(ddof=0) or 1.0)
761
+ predictors.append(xz * mz)
762
+ predictor_meta.append(("interaction", r))
763
+ if not predictors or target not in constructs:
764
+ continue
765
+ y = mean_scores[constructs[target]].to_numpy(dtype=float)
766
+ coef, r2 = self._standardized_ols(y, predictors)
767
+ r2_by_construct[target] = round(float(r2), 3)
768
+ model_specs[target] = (y, predictors, predictor_meta, coef)
769
+ for index, (kind, meta) in enumerate(predictor_meta):
770
+ estimate = round(float(coef[index]), 3)
771
+ if kind == "relation":
772
+ rid = str(meta.get("relation_id") or "")
773
+ relation_results[rid] = {
774
+ "relation_id": rid, "type": meta.get("type"),
775
+ "source": constructs.get(meta.get("source"), ""),
776
+ "target": constructs.get(meta.get("target"), ""),
777
+ "estimate": estimate, "r2_target": round(float(r2), 3),
778
+ }
779
+ elif kind == "interaction":
780
+ rid = str(meta.get("relation_id") or "")
781
+ relation_results[rid] = {
782
+ "relation_id": rid, "type": "moderation",
783
+ "source": constructs.get(meta.get("source"), ""),
784
+ "moderator": constructs.get(meta.get("moderator"), ""),
785
+ "target": constructs.get(meta.get("target"), ""),
786
+ "estimate": estimate, "r2_target": round(float(r2), 3),
787
+ "simple_slope_low": None, "simple_slope_high": None,
788
+ }
789
+
790
+ # Bootstrap relation and indirect-effect CIs from the same compiled model topology.
791
+ rng = np.random.default_rng(20260809)
792
+ relation_boot = {rid: [] for rid in relation_results}
793
+ indirect_boot = {str(e.get("effect_id") or ""): [] for e in (quant.get("indirect_effects") or []) if isinstance(e, dict)}
794
+ n = len(mean_scores)
795
+ for _ in range(int(bootstrap_iterations)):
796
+ idx = rng.integers(0, n, n)
797
+ boot_estimates = {}
798
+ try:
799
+ for target, (y, predictors, predictor_meta, _coef) in model_specs.items():
800
+ yb = y[idx]
801
+ xb = [arr[idx] for arr in predictors]
802
+ coef_b, _ = self._standardized_ols(yb, xb)
803
+ for j, (kind, meta) in enumerate(predictor_meta):
804
+ if kind in {"relation", "interaction"}:
805
+ rid = str(meta.get("relation_id") or "")
806
+ boot_estimates[rid] = float(coef_b[j])
807
+ if rid in relation_boot:
808
+ relation_boot[rid].append(float(coef_b[j]))
809
+ for effect in quant.get("indirect_effects") or []:
810
+ if not isinstance(effect, dict):
811
+ continue
812
+ eid = str(effect.get("effect_id") or "")
813
+ path = list(effect.get("path") or [])
814
+ product = 1.0
815
+ valid = True
816
+ for a, b in zip(path, path[1:]):
817
+ rid = next((str(r.get("relation_id") or "") for r in direct_like if r.get("source") == a and r.get("target") == b), "")
818
+ if not rid or rid not in boot_estimates:
819
+ valid = False; break
820
+ product *= boot_estimates[rid]
821
+ if valid and eid in indirect_boot:
822
+ indirect_boot[eid].append(float(product))
823
+ except Exception:
824
+ continue
825
+
826
+ for rid, values in relation_boot.items():
827
+ if len(values) >= 100:
828
+ lo, hi = np.percentile(values, [2.5, 97.5])
829
+ relation_results[rid]["ci"] = [round(float(lo), 3), round(float(hi), 3)]
830
+
831
+ # Recover simple slopes for moderation from standardized main + interaction terms.
832
+ for r in moderation:
833
+ rid = str(r.get("relation_id") or "")
834
+ result = relation_results.get(rid)
835
+ if not result:
836
+ continue
837
+ target = r.get("target")
838
+ _y, _preds, meta_rows, coef = model_specs.get(target, (None, None, [], []))
839
+ main_beta = None
840
+ for j, (kind, meta) in enumerate(meta_rows):
841
+ if kind in {"relation", "main"} and meta.get("source") == r.get("source"):
842
+ main_beta = float(coef[j]); break
843
+ if main_beta is not None:
844
+ interaction_beta = float(result["estimate"])
845
+ result["simple_slope_low"] = round(main_beta - interaction_beta, 3)
846
+ result["simple_slope_high"] = round(main_beta + interaction_beta, 3)
847
+
848
+ indirect_results = {}
849
+ for effect in quant.get("indirect_effects") or []:
850
+ if not isinstance(effect, dict):
851
+ continue
852
+ eid = str(effect.get("effect_id") or "")
853
+ path = list(effect.get("path") or [])
854
+ product = 1.0
855
+ labels = []
856
+ valid = True
857
+ for cid in path:
858
+ labels.append(constructs.get(cid, cid))
859
+ for a, b in zip(path, path[1:]):
860
+ rid = next((str(r.get("relation_id") or "") for r in direct_like if r.get("source") == a and r.get("target") == b), "")
861
+ if not rid or rid not in relation_results:
862
+ valid = False; break
863
+ product *= float(relation_results[rid]["estimate"])
864
+ if not valid:
865
+ continue
866
+ row = {"effect_id":eid,"path":labels,"estimate":round(product,3),"kind":effect.get("kind") or "simple"}
867
+ vals = indirect_boot.get(eid) or []
868
+ if len(vals) >= 100:
869
+ lo, hi = np.percentile(vals,[2.5,97.5]); row["ci"]=[round(float(lo),3),round(float(hi),3)]
870
+ indirect_results[eid] = row
871
+
872
+ path_rows = []
873
+ for r in relations:
874
+ rid = str(r.get("relation_id") or "")
875
+ rr = relation_results.get(rid)
876
+ if not rr:
877
+ continue
878
+ if r.get("type") == "moderation":
879
+ label = f"{rr['source']} × {rr['moderator']} → {rr['target']} (interaction)"
880
+ else:
881
+ label = f"{rr['source']} → {rr['target']}"
882
+ path_rows.append({"Association":label,"Standardized estimate":rr["estimate"],"R² of outcome":rr.get("r2_target","—")})
883
+ for row in indirect_results.values():
884
+ path_rows.append({"Association":"Indirect association: " + " → ".join(row["path"]),"Standardized estimate":row["estimate"],"R² of outcome":"—"})
885
+ path_table = pd.DataFrame(path_rows)
886
+
887
+ # Conservative multicollinearity summary across ordinary construct scores.
888
+ vif_values = []
889
+ for target, (_y, predictors, _meta, _coef) in model_specs.items():
890
+ if len(predictors) < 2:
891
+ continue
892
+ matrix = np.column_stack(predictors)
893
+ corr = np.corrcoef(matrix, rowvar=False)
894
+ try:
895
+ inv = np.linalg.pinv(corr)
896
+ vif_values.extend(float(x) for x in np.diag(inv))
897
+ except Exception:
898
+ pass
899
+
900
+ summary = {
901
+ "relation_results": relation_results,
902
+ "indirect_results": indirect_results,
903
+ "r2_by_construct": {constructs.get(k,k):v for k,v in r2_by_construct.items()},
904
+ "max_vif": round(max(vif_values),3) if vif_values else 1.0,
905
+ "bootstrap_iterations": int(bootstrap_iterations),
906
+ "study_design_contract_sha256": str((contract or {}).get("contract_sha256") or ""),
907
+ }
908
+
909
+ # Exact historical aliases keep legacy validators/checkpoints stable.
910
+ if bool(quant.get("legacy_three_construct_profile")):
911
+ p = next((x for x in quant.get("constructs") or [] if "predictor" in (x.get("roles") or [])), {})
912
+ m = next((x for x in quant.get("constructs") or [] if "mediator" in (x.get("roles") or [])), {})
913
+ o = next((x for x in quant.get("constructs") or [] if "outcome" in (x.get("roles") or [])), {})
914
+ pl, ml, ol = p.get("label"), m.get("label"), o.get("label")
915
+ def est(src,tgt):
916
+ for rr in relation_results.values():
917
+ if rr.get("source") == src and rr.get("target") == tgt and rr.get("type") == "direct_association": return rr.get("estimate")
918
+ return None
919
+ a, b, direct = est(pl,ml), est(ml,ol), est(pl,ol)
920
+ indirect = next(iter(indirect_results.values()), {})
921
+ summary.update({
922
+ "predictor":pl,"mediator":ml,"outcome":ol,
923
+ "a":a,"b":b,"direct":direct,"indirect":indirect.get("estimate"),
924
+ "r2_mediator":summary["r2_by_construct"].get(ml),
925
+ "r2_outcome":summary["r2_by_construct"].get(ol),
926
+ "indirect_ci":indirect.get("ci"),
927
+ "direct_ci":next((rr.get("ci") for rr in relation_results.values() if rr.get("source")==pl and rr.get("target")==ol), None),
928
+ "predictor_mediator_vif":summary["max_vif"],
929
+ })
930
+ return summary, path_table, mean_scores.corr(method="spearman")
931
+
932
+
933
  @staticmethod
934
  def _sentence_spans(text):
935
  """Return sentence spans without splitting decimal statistics or citations."""
 
1093
  }
1094
 
1095
  constructs = dict(meta.get("constructs") or {}) if isinstance(meta.get("constructs"), dict) else {}
1096
+ construct_items = [dict(item) for item in (meta.get("construct_items") or []) if isinstance(item, dict)]
1097
  results = dict(meta.get("association_results") or {}) if isinstance(meta.get("association_results"), dict) else {}
1098
+ study_contract = dict(meta.get("study_design_contract") or {}) if isinstance(meta.get("study_design_contract"), dict) else {}
1099
+ analysis_plan = list(dict(study_contract.get("quantitative") or {}).get("analysis_plan") or [])
1100
+ allowed_methods = [
1101
+ "reliability", "composite reliability", "average variance extracted",
1102
+ "Fornell-Larcker", "standardized association", "bootstrap",
1103
+ "Spearman", "VIF", "descriptive statistics",
1104
+ ]
1105
+ if "interaction_test" in analysis_plan:
1106
+ allowed_methods.extend(["interaction term", "moderation", "simple slopes"])
1107
  return {
1108
  "version": cls.QUANTITATIVE_EVIDENCE_LEDGER_VERSION,
1109
  "source_type": source_type,
 
1111
  "sample_size": int(sample_size) if isinstance(sample_size, (int, np.integer)) or str(sample_size or "").isdigit() else None,
1112
  "scale_points": int(scale_points) if isinstance(scale_points, (int, np.integer)) or str(scale_points or "").isdigit() else None,
1113
  "study_design": str(meta.get("study_design") or "").strip(),
1114
+ "study_design_contract": study_contract,
1115
  "constructs": {k: str(v or "").strip() for k, v in constructs.items() if str(v or "").strip()},
1116
+ "construct_items": construct_items,
1117
  "association_results": results,
1118
  "uploaded_metric_anchors": uploaded_metric_anchors,
1119
  "causal_identification": "none" if strict else "evidence_owned",
1120
+ "allowed_methods": allowed_methods if strict else [],
 
 
 
 
1121
  "forbidden_unowned_methods": [
1122
  "confirmatory factor analysis", "CFA", "structural equation modeling", "SEM",
1123
  "PLS-SEM", "RMSEA", "CFI", "TLI", "SRMR", "Harman",
 
1827
 
1828
  @classmethod
1829
  def _strict_standardized_metric_occurrences(cls, sentence, constructs, results):
1830
+ """Yield clause-bound owners for legacy and adaptive graph estimates."""
1831
  regex = re.compile(r"(?i)((?:β|standardized\s+estimate)\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)")
1832
+ legacy_expected = {key: results.get(key) for key in ("a", "b", "direct", "indirect")}
1833
+ relation_results = dict(results.get("relation_results") or {}) if isinstance(results, dict) else {}
1834
+ indirect_results = dict(results.get("indirect_results") or {}) if isinstance(results, dict) else {}
1835
  for match in regex.finditer(str(sentence or "")):
1836
  owner, clause = cls._quantitative_metric_owner_for_occurrence(sentence, match, constructs)
1837
+ if owner and legacy_expected.get(owner) is not None:
1838
+ yield match, owner, legacy_expected.get(owner), clause
1839
+ continue
1840
+ folded = cls._normalize_quantitative_construct_surface(clause)
1841
+ candidates = []
1842
+ for rid, row in relation_results.items():
1843
+ if not isinstance(row, dict):
1844
+ continue
1845
+ source = cls._normalize_quantitative_construct_surface(row.get("source"))
1846
+ target = cls._normalize_quantitative_construct_surface(row.get("target"))
1847
+ moderator = cls._normalize_quantitative_construct_surface(row.get("moderator"))
1848
+ if not source or not target or source not in folded or target not in folded:
1849
+ continue
1850
+ if row.get("type") == "moderation":
1851
+ if not moderator or moderator not in folded or not re.search(r"(?i)interaction|moderat|×", clause):
1852
+ continue
1853
+ elif re.search(r"(?i)interaction|moderat|×", clause):
1854
+ continue
1855
+ if row.get("estimate") is not None:
1856
+ candidates.append((f"relation:{rid}", row.get("estimate")))
1857
+ if re.search(r"(?i)indirect", clause):
1858
+ for eid, row in indirect_results.items():
1859
+ if not isinstance(row, dict):
1860
+ continue
1861
+ labels = [cls._normalize_quantitative_construct_surface(x) for x in (row.get("path") or [])]
1862
+ if labels and all(label in folded for label in labels) and row.get("estimate") is not None:
1863
+ candidates.append((f"indirect:{eid}", row.get("estimate")))
1864
+ if len(candidates) == 1:
1865
+ yield match, candidates[0][0], candidates[0][1], clause
1866
+ else:
1867
+ yield match, None, None, clause
1868
 
1869
  @classmethod
1870
  def inspect_quantitative_study_fact_contract(cls, text, metadata, section_name=""):
 
1973
  "preview":sentence.strip()[:180],
1974
  })
1975
 
1976
+ vif_expected = (results.get("predictor_mediator_vif") if results.get("predictor_mediator_vif") is not None else results.get("max_vif")) if strict else uploaded.get("VIF")
1977
  boot_expected = results.get("bootstrap_iterations") if strict else uploaded.get("bootstrap_iterations")
1978
  add_metric_violations(sentence, r"(\bVIF\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", vif_expected, label="VIF")
1979
  add_metric_violations(sentence, r"(\bbootstrap\s+(?:resamples?|iterations?)\s*(?:=|:)?\s*)(\d{2,6})", boot_expected, dtype="int", label="bootstrap_iterations")
 
1982
  construct = str(constructs.get(role) or "")
1983
  if construct and results.get(key) is not None:
1984
  add_metric_violations(sentence, rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", results.get(key), label=key)
1985
+ for construct_label, expected_r2 in dict(results.get("r2_by_construct") or {}).items():
1986
+ if construct_label and expected_r2 is not None:
1987
+ add_metric_violations(sentence, rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(str(construct_label))}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", expected_r2, label=f"r2:{construct_label}")
1988
  # R6-R14 clause-bound metric ownership: validate each occurrence
1989
  # against its own local construct relation, never one owner per sentence.
1990
  for metric_match, metric_owner, metric_expected, metric_clause in cls._strict_standardized_metric_occurrences(
 
2152
  return match.group(1) + expected_text
2153
  revised = regex.sub(repl, revised)
2154
 
2155
+ vif_expected = (results.get("predictor_mediator_vif") if results.get("predictor_mediator_vif") is not None else results.get("max_vif")) if strict else uploaded.get("VIF")
2156
  boot_expected = results.get("bootstrap_iterations") if strict else uploaded.get("bootstrap_iterations")
2157
  replace_local_metric(r"(\bVIF\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)", vif_expected, label="VIF")
2158
  replace_local_metric(r"(\bbootstrap\s+(?:resamples?|iterations?)\s*(?:=|:)?\s*)(\d{2,6})", boot_expected, dtype="int", label="bootstrap_iterations")
 
2163
  if construct and results.get(key) is not None:
2164
  metric_pattern = rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(construct)}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)"
2165
  replace_local_metric(metric_pattern, results.get(key), label=key)
2166
+ for construct_label, expected_r2 in dict(results.get("r2_by_construct") or {}).items():
2167
+ if construct_label and expected_r2 is not None:
2168
+ metric_pattern = rf"(R(?:²|\^2)\s*(?:for|of)?\s*{re.escape(str(construct_label))}\s*(?:=|:)?\s*)([-+]?\d+(?:\.\d+)?)"
2169
+ replace_local_metric(metric_pattern, expected_r2, label=f"r2:{construct_label}")
2170
 
2171
  # R6-R14 deterministic repair is also occurrence-local. Never
2172
  # rewrite every metric in a sentence to one selected path value.
 
2234
  if user_md:
2235
  evidence_sections = [
2236
  heading.strip()
2237
+ for heading in re.findall(r"(?m)^#{1,3}\s+(.+?)\s*$", user_md)
 
 
 
2238
  if heading.strip()
2239
  ]
2240
  metadata = {
2241
  "mode": "quantitative",
2242
  "source_type": "user_provided",
2243
  "disclosure_required": False,
2244
+ "evidence_sections": evidence_sections[:20] or ["User-provided quantitative evidence"],
 
2245
  "evidence_boundary": (
2246
  "Only values explicitly present in the uploaded evidence are frozen. "
2247
  "Do not infer absent setting, sample, measures, software, or results."
 
2250
  metadata["quantitative_evidence_ledger"] = self.build_quantitative_evidence_ledger(metadata, user_md)
2251
  return None, user_md, tr(language, "actual_stats_parsed"), metadata
2252
 
2253
+ design_contract = self._derive_system_design(agent, topic, keywords)
2254
+ issues = validate_quantitative_contract(design_contract)
2255
+ if issues:
2256
+ raise RuntimeError(f"adaptive_quantitative_study_design_invalid:{issues[:4]}")
2257
+ quant = dict(design_contract.get("quantitative") or {})
2258
+ construct_items = [dict(item) for item in (quant.get("constructs") or []) if isinstance(item, dict)]
2259
+ construct_names = [str(item.get("label") or "").strip() for item in construct_items if str(item.get("label") or "").strip()]
2260
+ if len(construct_names) < 2:
2261
+ raise RuntimeError("adaptive_quantitative_study_design_has_too_few_constructs")
2262
+
2263
+ df, validity_df, fl_matrix_df, mean_scores, _corr_matrix = self.generate_empirical_data_robust(
2264
  construct_names=construct_names
2265
  )
2266
+ path_summary, path_table, spearman = self._compile_graph_evidence(mean_scores, design_contract)
2267
+ context = str(dict(design_contract.get("context") or {}).get("research_context") or "").strip()
2268
+
2269
+ role_lines = []
2270
+ for item in construct_items:
2271
+ role_lines.append(
2272
+ f"| {item.get('construct_id')} | {item.get('label')} | {', '.join(item.get('roles') or [])} |"
2273
+ )
2274
+ relation_lines = []
2275
+ by_id = {item.get("construct_id"): item.get("label") for item in construct_items}
2276
+ for rel in quant.get("relations") or []:
2277
+ if not isinstance(rel, dict):
2278
+ continue
2279
+ source = by_id.get(rel.get("source"), rel.get("source"))
2280
+ target = by_id.get(rel.get("target"), rel.get("target"))
2281
+ moderator = by_id.get(rel.get("moderator"), rel.get("moderator")) if rel.get("moderator") else "—"
2282
+ relation_lines.append(
2283
+ f"| {rel.get('relation_id')} | {rel.get('type')} | {source} | {target} | {moderator} |"
2284
+ )
2285
+
2286
+ bootstrap_lines = [f"- Bootstrap resamples: {path_summary.get('bootstrap_iterations', 1500)}."]
2287
+ for rid, row in dict(path_summary.get("relation_results") or {}).items():
2288
+ label = f"{row.get('source')} → {row.get('target')}"
2289
+ if row.get("type") == "moderation":
2290
+ label = f"{row.get('source')} × {row.get('moderator')} → {row.get('target')}"
2291
+ ci = row.get("ci") or []
2292
+ ci_text = f"; 95% bootstrap CI [{ci[0]}, {ci[1]}]" if len(ci) == 2 else ""
2293
+ extra = ""
2294
+ if row.get("type") == "moderation" and row.get("simple_slope_low") is not None:
2295
+ extra = f"; standardized simple slopes at moderator -1 SD/+1 SD: {row.get('simple_slope_low')}/{row.get('simple_slope_high')}"
2296
+ bootstrap_lines.append(f"- {rid} {label}: standardized estimate {row.get('estimate')}{ci_text}{extra}.")
2297
+ for eid, row in dict(path_summary.get("indirect_results") or {}).items():
2298
+ ci = row.get("ci") or []
2299
+ ci_text = f"; 95% bootstrap CI [{ci[0]}, {ci[1]}]" if len(ci) == 2 else ""
2300
+ bootstrap_lines.append(
2301
+ f"- {eid} indirect association ({' → '.join(row.get('path') or [])}): {row.get('estimate')}{ci_text}."
2302
+ )
2303
+ bootstrap_lines.append(f"- Maximum predictor-set VIF across compiled regression models: {path_summary.get('max_vif', 1.0)}.")
2304
+
2305
  generated_prompt = f"""### Frozen Quantitative Study Design
2306
  | Field | Frozen value |
2307
  | :--- | :--- |
 
2309
  | Respondent context | {context} |
2310
  | Sample size | {len(df)} |
2311
  | Response scale | 7-point Likert-type scale |
2312
+ | Causal identification | None; report associations, indirect associations, and interaction associations only; do not claim causal effects |
2313
+ | Study design contract | {design_contract.get('contract_id')} |
2314
+
2315
+ ### Frozen Construct Graph
2316
+ | Construct ID | Construct | Role(s) |
2317
+ | :--- | :--- | :--- |
2318
+ {chr(10).join(role_lines)}
2319
+
2320
+ ### Frozen Relation Graph
2321
+ | Relation ID | Type | Source | Target | Moderator |
2322
+ | :--- | :--- | :--- | :--- | :--- |
2323
+ {chr(10).join(relation_lines)}
2324
 
2325
  ### Table 1: Reliability and Convergent Validity
2326
  {validity_df.to_markdown(index=False)}
 
2335
  ### Table 4: Rank-Order Robustness Correlations (Spearman)
2336
  {spearman.round(3).to_markdown()}
2337
 
2338
+ ### Frozen Bootstrap, Interaction, and Collinearity Checks
2339
+ {chr(10).join(bootstrap_lines)}
 
 
 
2340
 
2341
  [STRICT EMPIRICAL INSTRUCTION]
2342
  1. ZERO HALLUCINATION: Every study-specific fact and number must come from this frozen packet. Never invent a country, named organization, sampling frame, response rate, demographic characteristic, measure source, software package, coefficient, p-value, fit index, robustness test, or date.
2343
+ 2. DESIGN DISCIPLINE: The frozen Study Design Contract and relation graph are the sole authority for what this study measures/models. Never add a mediator, moderator, outcome, control, interaction, indirect path, or tested relation that is absent from the graph.
2344
+ 3. INFERENCE DISCIPLINE: This is a cross-sectional observational survey. Use association language. Indirect estimates are not causal mediation identification; interaction estimates are associational moderation tests.
2345
+ 4. MEASUREMENT DEFENSE: State reliability/convergent/discriminant validity only when the displayed values support it.
2346
+ 5. RESULTS DISCIPLINE: Results must report this study's frozen numbers without attaching external citations to coefficients or sample facts.
2347
+ 6. ROBUSTNESS: The Spearman matrix, frozen bootstrap intervals, simple slopes when present, and VIF above are the only frozen robustness evidence. Do not add another robustness procedure.
2348
+ 7. CONSISTENCY: Construct identities, roles, relation IDs, sample size, context, scale, and all numerical results above are the single source of quantitative truth throughout the manuscript.
2349
  """
2350
+
2351
+ role_map = {}
2352
+ for role in ("predictor", "mediator", "moderator", "outcome", "control", "covariate"):
2353
+ vals = [item.get("label") for item in construct_items if role in (item.get("roles") or [])]
2354
+ if len(vals) == 1:
2355
+ role_map[role] = vals[0]
2356
+
2357
  metadata = {
2358
  "mode": "quantitative",
2359
  "source_type": "system_generated",
 
2362
  "scale_points": 7,
2363
  "study_design": "cross-sectional single-wave questionnaire",
2364
  "research_context": context,
2365
+ "study_design_contract": design_contract,
2366
+ "constructs": role_map,
2367
+ "construct_items": construct_items,
 
 
2368
  "association_results": path_summary,
2369
  "method": (
2370
+ "Positive-semidefinite latent correlation generation, indicator loadings, Likert discretization, "
2371
+ "reliability/validity summaries, Study-Design-Contract-compiled standardized association models, "
2372
+ "fixed-seed bootstrap intervals, interaction/simple-slope estimates when frozen, and Spearman robustness correlations."
2373
  ),
2374
  "generation_parameters": {
2375
  "constructs": construct_names,
2376
+ "study_design_contract_sha256": design_contract.get("contract_sha256"),
2377
+ "analysis_plan": list(quant.get("analysis_plan") or []),
2378
  "sample_size": int(len(df)),
2379
  "likert_scale_points": 7,
2380
  "items_per_construct": "random integer from 3 to 5",
 
2382
  "latent_correlation_control": "positive-semidefinite matrix with maximum target correlation 0.55",
2383
  "item_generation": "loading × latent score + normally distributed measurement error",
2384
  "discretization": "round to nearest integer and clamp to 1–7",
2385
+ "bootstrap_iterations": path_summary.get("bootstrap_iterations", 1500),
2386
  "bootstrap_seed": 20260809,
2387
  "reported_summaries": [
2388
+ "Cronbach alpha", "composite reliability", "average variance extracted",
2389
+ "descriptive statistics", "Fornell-Larcker matrix", "compiled standardized association estimates",
2390
+ "R-squared", "bootstrap confidence intervals", "interaction/simple-slope estimates when frozen",
2391
+ "Spearman correlations", "VIF",
 
 
 
 
 
 
2392
  ],
2393
  },
2394
  "evidence_sections": [
2395
+ "Frozen quantitative study design", "Frozen construct graph", "Frozen relation graph",
2396
+ "Reliability and convergent validity", "Descriptive statistics and discriminant validity",
2397
+ "Frozen association model results", "Rank-order robustness correlations",
2398
+ "Frozen bootstrap, interaction, and collinearity checks",
 
 
2399
  ],
2400
  }
2401
  metadata["semantic_claim_frame_version"] = self.SEMANTIC_CLAIM_FRAME_VERSION
2402
+ metadata["front_matter_contract"] = self.build_front_matter_contract(topic, keywords, design_contract)
2403
+ metadata["hypothesis_ledger"] = self.build_hypothesis_ledger(design_contract)
2404
+ metadata["quantitative_table_manifest"] = self.build_table_manifest(validity_df, fl_matrix_df, path_table, spearman)
 
 
 
 
2405
  metadata["quantitative_evidence_ledger"] = self.build_quantitative_evidence_ledger(metadata, generated_prompt)
2406
+ return df, generated_prompt, tr(language, "synthetic_stats_generated"), metadata
 
 
 
 
 
2407
 
2408
 
2409
  def get_quantitative_engine():
core/study_design_contract.py ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Adaptive study-design contracts shared by SmartPaper quantitative/qualitative paths.
3
+
4
+ The LLM may propose a candidate design, but this module owns deterministic
5
+ normalization, identity, validation, and scope semantics. It deliberately
6
+ contains no provider/model routing logic.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ from typing import Any
14
+
15
+ STUDY_DESIGN_CONTRACT_VERSION = "smartpaper-adaptive-study-design-contract-v1"
16
+
17
+ _QUANT_ROLES = {"predictor", "mediator", "moderator", "outcome", "control", "covariate"}
18
+ _QUANT_RELATIONS = {"direct_association", "moderation", "control_effect"}
19
+ _QUAL_SOURCE_FAMILIES = {
20
+ "interview", "focus_group", "observation", "document", "log",
21
+ "artifact", "digital_trace", "field_note", "other",
22
+ }
23
+
24
+
25
+ def _clean(value: Any, maximum: int = 240) -> str:
26
+ text = re.sub(r"\s+", " ", str(value or "")).strip(" \t\r\n,;:.-")
27
+ return text[:maximum]
28
+
29
+
30
+ def _keywords(value: Any) -> list[str]:
31
+ raw = value if isinstance(value, (list, tuple)) else re.split(r"[,;\n|]+", str(value or ""))
32
+ out: list[str] = []
33
+ seen: set[str] = set()
34
+ for item in raw:
35
+ text = _clean(item, 100)
36
+ key = text.casefold()
37
+ if text and key not in seen:
38
+ out.append(text)
39
+ seen.add(key)
40
+ return out[:16]
41
+
42
+
43
+ def _stable_hash(payload: dict[str, Any]) -> str:
44
+ encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
45
+ return hashlib.sha256(encoded).hexdigest()
46
+
47
+
48
+ def _finalize(contract: dict[str, Any]) -> dict[str, Any]:
49
+ value = dict(contract)
50
+ value.pop("contract_sha256", None)
51
+ value.pop("contract_id", None)
52
+ digest = _stable_hash(value)
53
+ value["contract_id"] = digest[:20]
54
+ value["contract_sha256"] = digest
55
+ return value
56
+
57
+
58
+ def _normalize_quant_relation_type(value: Any) -> str:
59
+ text = _clean(value, 80).casefold().replace("-", "_").replace(" ", "_")
60
+ aliases = {
61
+ "association": "direct_association", "direct": "direct_association",
62
+ "direct_effect": "direct_association", "direct_association": "direct_association",
63
+ "path": "direct_association", "mediation_path": "direct_association",
64
+ "moderator": "moderation", "moderation": "moderation", "interaction": "moderation",
65
+ "interaction_effect": "moderation", "control": "control_effect", "control_effect": "control_effect",
66
+ }
67
+ return aliases.get(text, text)
68
+
69
+
70
+ def _normalize_roles(value: Any) -> list[str]:
71
+ raw = value if isinstance(value, (list, tuple)) else [value]
72
+ out: list[str] = []
73
+ for item in raw:
74
+ role = _clean(item, 40).casefold().replace(" ", "_")
75
+ if role in _QUANT_ROLES and role not in out:
76
+ out.append(role)
77
+ return out
78
+
79
+
80
+ def _legacy_quant_candidate(constructs: list[str], context: str = "") -> dict[str, Any]:
81
+ values = [_clean(item, 80) for item in constructs if _clean(item, 80)]
82
+ if len(values) != 3:
83
+ values = ["Focal Predictor", "Focal Mediator", "Focal Outcome"]
84
+ p, m, o = values
85
+ return {
86
+ "constructs": [
87
+ {"label": p, "roles": ["predictor"]},
88
+ {"label": m, "roles": ["mediator"]},
89
+ {"label": o, "roles": ["outcome"]},
90
+ ],
91
+ "relations": [
92
+ {"source": p, "target": o, "type": "direct_association", "hypothesis_order": 1},
93
+ {"source": p, "target": m, "type": "direct_association", "hypothesis_order": 2},
94
+ {"source": m, "target": o, "type": "direct_association", "hypothesis_order": 3},
95
+ ],
96
+ "indirect_effects": [{"path": [p, m, o], "kind": "simple", "numbered_hypothesis": False}],
97
+ "research_context": context,
98
+ "background_concepts": [],
99
+ }
100
+
101
+
102
+ def build_quantitative_contract(
103
+ topic: str,
104
+ keywords: Any,
105
+ candidate: Any,
106
+ *,
107
+ source_type: str = "system_generated",
108
+ policy_version: str = "adaptive-quantitative-v1",
109
+ ) -> dict[str, Any]:
110
+ """Normalize a model-proposed quantitative graph into deterministic authority."""
111
+ raw = dict(candidate or {}) if isinstance(candidate, dict) else {}
112
+ raw_constructs = raw.get("constructs") or []
113
+ if raw_constructs and all(not isinstance(item, dict) for item in raw_constructs):
114
+ raw = _legacy_quant_candidate(list(raw_constructs), _clean(raw.get("research_context")))
115
+ raw_constructs = raw["constructs"]
116
+
117
+ constructs: list[dict[str, Any]] = []
118
+ label_to_id: dict[str, str] = {}
119
+ for item in raw_constructs[:8]:
120
+ if not isinstance(item, dict):
121
+ continue
122
+ label = _clean(item.get("label") or item.get("name"), 80)
123
+ if not label or label.casefold() in label_to_id:
124
+ continue
125
+ roles = _normalize_roles(item.get("roles") or item.get("role"))
126
+ if not roles:
127
+ continue
128
+ cid = f"C{len(constructs) + 1:03d}"
129
+ row = {
130
+ "construct_id": cid,
131
+ "label": label,
132
+ "roles": roles,
133
+ "measurement_model": _clean(item.get("measurement_model") or "reflective", 40).casefold() or "reflective",
134
+ "measurement_allowed": bool(item.get("measurement_allowed", True)),
135
+ }
136
+ constructs.append(row)
137
+ label_to_id[label.casefold()] = cid
138
+
139
+ # Safe compatibility fallback; importantly this is a fallback, not the normal schema.
140
+ if len(constructs) < 2:
141
+ raw = _legacy_quant_candidate(["Focal Predictor", "Focal Mediator", "Focal Outcome"], _clean(raw.get("research_context")))
142
+ return build_quantitative_contract(topic, keywords, raw, source_type=source_type, policy_version=policy_version)
143
+
144
+ by_id = {row["construct_id"]: row for row in constructs}
145
+ def resolve(ref: Any) -> str:
146
+ text = _clean(ref, 80)
147
+ if text in by_id:
148
+ return text
149
+ return label_to_id.get(text.casefold(), "")
150
+
151
+ relations: list[dict[str, Any]] = []
152
+ rejected: list[dict[str, Any]] = []
153
+ for item in (raw.get("relations") or [])[:24]:
154
+ if not isinstance(item, dict):
155
+ continue
156
+ rtype = _normalize_quant_relation_type(item.get("type"))
157
+ source = resolve(item.get("source"))
158
+ target = resolve(item.get("target"))
159
+ moderator = resolve(item.get("moderator")) if item.get("moderator") else ""
160
+ if rtype not in _QUANT_RELATIONS or not source or not target or source == target:
161
+ rejected.append({"reason": "invalid_or_unsupported_relation", "raw": {k: item.get(k) for k in ("type", "source", "target", "moderator")}})
162
+ continue
163
+ if rtype == "moderation" and (not moderator or moderator in {source, target}):
164
+ rejected.append({"reason": "moderation_requires_distinct_moderator", "raw": {k: item.get(k) for k in ("type", "source", "target", "moderator")}})
165
+ continue
166
+ relation = {
167
+ "relation_id": f"R{len(relations) + 1:03d}",
168
+ "type": rtype,
169
+ "source": source,
170
+ "target": target,
171
+ "moderator": moderator or None,
172
+ "analysis_target": bool(item.get("analysis_target", True)),
173
+ "numbered_hypothesis": bool(item.get("numbered_hypothesis", rtype != "control_effect")),
174
+ }
175
+ try:
176
+ relation["hypothesis_order"] = int(item.get("hypothesis_order")) if item.get("hypothesis_order") is not None else None
177
+ except Exception:
178
+ relation["hypothesis_order"] = None
179
+ relations.append(relation)
180
+
181
+ # If the interpreter omitted relations but roles make a parsimonious graph unambiguous,
182
+ # compile a safe role-derived graph rather than silently returning an empty model.
183
+ if not relations:
184
+ predictors = [x["construct_id"] for x in constructs if "predictor" in x["roles"]]
185
+ mediators = [x["construct_id"] for x in constructs if "mediator" in x["roles"]]
186
+ outcomes = [x["construct_id"] for x in constructs if "outcome" in x["roles"]]
187
+ moderators = [x["construct_id"] for x in constructs if "moderator" in x["roles"]]
188
+ if predictors and outcomes:
189
+ p, o = predictors[0], outcomes[0]
190
+ if mediators:
191
+ m = mediators[0]
192
+ relations.extend([
193
+ {"relation_id":"R001","type":"direct_association","source":p,"target":o,"moderator":None,"analysis_target":True,"numbered_hypothesis":True,"hypothesis_order":1},
194
+ {"relation_id":"R002","type":"direct_association","source":p,"target":m,"moderator":None,"analysis_target":True,"numbered_hypothesis":True,"hypothesis_order":2},
195
+ {"relation_id":"R003","type":"direct_association","source":m,"target":o,"moderator":None,"analysis_target":True,"numbered_hypothesis":True,"hypothesis_order":3},
196
+ ])
197
+ else:
198
+ relations.append({"relation_id":"R001","type":"direct_association","source":p,"target":o,"moderator":None,"analysis_target":True,"numbered_hypothesis":True,"hypothesis_order":1})
199
+ if moderators:
200
+ relations.append({"relation_id":f"R{len(relations)+1:03d}","type":"moderation","source":p,"target":o,"moderator":moderators[0],"analysis_target":True,"numbered_hypothesis":True,"hypothesis_order":len(relations)+1})
201
+
202
+ direct_pairs = {(r["source"], r["target"]): r["relation_id"] for r in relations if r["type"] == "direct_association"}
203
+ indirect_effects: list[dict[str, Any]] = []
204
+ raw_indirect = raw.get("indirect_effects") or []
205
+ for item in raw_indirect[:12]:
206
+ if not isinstance(item, dict):
207
+ continue
208
+ path = [resolve(x) for x in (item.get("path") or [])]
209
+ path = [x for x in path if x]
210
+ if len(path) < 3 or any((a, b) not in direct_pairs for a, b in zip(path, path[1:])):
211
+ rejected.append({"reason":"invalid_indirect_path","raw":{"path": item.get("path")}})
212
+ continue
213
+ indirect_effects.append({
214
+ "effect_id": f"I{len(indirect_effects)+1:03d}",
215
+ "path": path,
216
+ "kind": _clean(item.get("kind") or ("simple" if len(path)==3 else "serial"), 30).casefold(),
217
+ "numbered_hypothesis": bool(item.get("numbered_hypothesis", False)),
218
+ })
219
+ if not indirect_effects:
220
+ # Infer valid mediator chains from role identity and existing path edges.
221
+ predictors = [x["construct_id"] for x in constructs if "predictor" in x["roles"]]
222
+ mediators = [x["construct_id"] for x in constructs if "mediator" in x["roles"]]
223
+ outcomes = [x["construct_id"] for x in constructs if "outcome" in x["roles"]]
224
+ for p in predictors:
225
+ for m in mediators:
226
+ for o in outcomes:
227
+ if (p, m) in direct_pairs and (m, o) in direct_pairs:
228
+ indirect_effects.append({"effect_id":f"I{len(indirect_effects)+1:03d}","path":[p,m,o],"kind":"simple","numbered_hypothesis":False})
229
+
230
+ controls = [x["construct_id"] for x in constructs if set(x["roles"]) & {"control", "covariate"}]
231
+ outcomes = [x["construct_id"] for x in constructs if "outcome" in x["roles"]]
232
+ if not outcomes:
233
+ targeted = {r["target"] for r in relations if r["type"] != "control_effect"}
234
+ outcomes = sorted(targeted)
235
+
236
+ analysis_plan = ["measurement_assessment"]
237
+ if any(r["type"] == "direct_association" for r in relations): analysis_plan.append("direct_association")
238
+ if indirect_effects: analysis_plan.append("indirect_effect")
239
+ if any(r["type"] == "moderation" for r in relations): analysis_plan.extend(["interaction_test", "simple_slopes"])
240
+ analysis_plan.append("robustness")
241
+
242
+ # Legacy profile is used only to preserve exact prior H ordering and validators.
243
+ role_map = {role: [x for x in constructs if role in x["roles"]] for role in ("predictor","mediator","outcome")}
244
+ legacy_profile = (
245
+ len(constructs) == 3
246
+ and all(len(role_map[r]) == 1 for r in role_map)
247
+ and not any("moderator" in x["roles"] or set(x["roles"]) & {"control","covariate"} for x in constructs)
248
+ and len([r for r in relations if r["type"] == "direct_association"]) == 3
249
+ )
250
+
251
+ unsupported = _keywords(raw.get("background_concepts") or raw.get("unsupported_seed_concepts") or [])
252
+ context = _clean(raw.get("research_context") or "respondents with experience relevant to the focal management phenomenon", 240)
253
+ contract = {
254
+ "version": STUDY_DESIGN_CONTRACT_VERSION,
255
+ "mode": "quantitative",
256
+ "source_type": _clean(source_type, 40) or "system_generated",
257
+ "source_topic": _clean(topic, 500),
258
+ "source_keywords": _keywords(keywords),
259
+ "research_problem": _clean(raw.get("research_problem"), 500),
260
+ "research_questions": _keywords(raw.get("research_questions") or []),
261
+ "design_family": "cross_sectional_observational_survey",
262
+ "evidence_strength": {
263
+ "causal_identification": False,
264
+ "allowed_inference": ["association", "estimated_indirect_association", "interaction_association"],
265
+ "forbidden_inference": ["causal_effect", "causal_mediation_identification"],
266
+ },
267
+ "context": {"research_context": context},
268
+ "quantitative": {
269
+ "constructs": constructs,
270
+ "relations": relations,
271
+ "indirect_effects": indirect_effects,
272
+ "controls": controls,
273
+ "outcomes": outcomes,
274
+ "analysis_plan": analysis_plan,
275
+ "legacy_three_construct_profile": legacy_profile,
276
+ },
277
+ "qualitative": None,
278
+ "unsupported_seed_concepts": unsupported,
279
+ "compiler_decisions": rejected,
280
+ "policy_version": policy_version,
281
+ }
282
+ return _finalize(contract)
283
+
284
+
285
+ def validate_quantitative_contract(contract: dict[str, Any]) -> list[str]:
286
+ issues: list[str] = []
287
+ record = dict(contract or {})
288
+ if record.get("version") != STUDY_DESIGN_CONTRACT_VERSION or record.get("mode") != "quantitative":
289
+ return ["invalid_contract_identity"]
290
+ quant = dict(record.get("quantitative") or {})
291
+ constructs = [x for x in (quant.get("constructs") or []) if isinstance(x, dict)]
292
+ ids = {x.get("construct_id") for x in constructs}
293
+ if len(ids) != len(constructs) or len(constructs) < 2:
294
+ issues.append("invalid_construct_identity")
295
+ for r in quant.get("relations") or []:
296
+ if not isinstance(r, dict):
297
+ issues.append("invalid_relation_record"); continue
298
+ if r.get("source") not in ids or r.get("target") not in ids:
299
+ issues.append("relation_references_unknown_construct")
300
+ if r.get("type") == "moderation" and r.get("moderator") not in ids:
301
+ issues.append("moderation_references_unknown_moderator")
302
+ return list(dict.fromkeys(issues))
303
+
304
+
305
+ def compile_quantitative_hypothesis_ledger(contract: dict[str, Any], *, version: str) -> dict[str, Any]:
306
+ quant = dict((contract or {}).get("quantitative") or {})
307
+ constructs = {x.get("construct_id"): x for x in (quant.get("constructs") or []) if isinstance(x, dict)}
308
+ rels = [dict(r) for r in (quant.get("relations") or []) if isinstance(r, dict) and r.get("numbered_hypothesis")]
309
+ rels.sort(key=lambda r: (r.get("hypothesis_order") is None, r.get("hypothesis_order") or 999, r.get("relation_id") or ""))
310
+ items: list[dict[str, Any]] = []
311
+ for r in rels:
312
+ source = constructs.get(r.get("source"), {}).get("label")
313
+ target = constructs.get(r.get("target"), {}).get("label")
314
+ moderator = constructs.get(r.get("moderator"), {}).get("label") if r.get("moderator") else None
315
+ if not source or not target:
316
+ continue
317
+ row = {
318
+ "hypothesis_id": f"H{len(items)+1}",
319
+ "predictor": source,
320
+ "outcome": target,
321
+ "relation": "moderation" if r.get("type") == "moderation" else "association",
322
+ "relation_id": r.get("relation_id"),
323
+ }
324
+ if moderator:
325
+ row["moderator"] = moderator
326
+ items.append(row)
327
+ indirect_rows = []
328
+ for effect in quant.get("indirect_effects") or []:
329
+ if not isinstance(effect, dict): continue
330
+ labels = [constructs.get(cid, {}).get("label") for cid in effect.get("path") or []]
331
+ labels = [x for x in labels if x]
332
+ if len(labels) >= 3:
333
+ indirect_rows.append({
334
+ "effect_id": effect.get("effect_id"), "path": labels,
335
+ "predictor": labels[0], "mediator": " -> ".join(labels[1:-1]), "outcome": labels[-1],
336
+ "numbered_hypothesis": bool(effect.get("numbered_hypothesis", False)),
337
+ "relation": "estimated_indirect_association",
338
+ })
339
+ ledger = {"version": version, "items": items, "indirect_analyses": indirect_rows}
340
+ # Exact compatibility key for the historical single-mediator profile.
341
+ if len(indirect_rows) == 1 and len(indirect_rows[0].get("path") or []) == 3:
342
+ p = indirect_rows[0]
343
+ ledger["indirect_analysis"] = {
344
+ "predictor": p["path"][0], "mediator": p["path"][1], "outcome": p["path"][2],
345
+ "numbered_hypothesis": p["numbered_hypothesis"], "relation": p["relation"],
346
+ }
347
+ return ledger
348
+
349
+
350
+ def build_quantitative_front_matter_contract(contract: dict[str, Any], *, version: str) -> dict[str, Any]:
351
+ quant = dict((contract or {}).get("quantitative") or {})
352
+ items = [dict(x) for x in (quant.get("constructs") or []) if isinstance(x, dict)]
353
+ # Preserve legacy role map where a role is singular, while adding an extensible item list.
354
+ role_map: dict[str, str] = {}
355
+ for role in ("predictor", "mediator", "moderator", "outcome", "control", "covariate"):
356
+ values = [x.get("label") for x in items if role in (x.get("roles") or []) and x.get("label")]
357
+ if len(values) == 1:
358
+ role_map[role] = values[0]
359
+ construct_roles = [r for r in ("predictor","mediator","moderator","outcome","control","covariate") if r in role_map]
360
+ return {
361
+ "version": version,
362
+ "policy": "adaptive_study_design_frozen_semantic_authority",
363
+ "source_topic": str((contract or {}).get("source_topic") or ""),
364
+ "source_keywords": list((contract or {}).get("source_keywords") or []),
365
+ "construct_roles": construct_roles,
366
+ "constructs": role_map,
367
+ "construct_items": items,
368
+ "relations": list(quant.get("relations") or []),
369
+ "unsupported_seed_concepts": list((contract or {}).get("unsupported_seed_concepts") or []),
370
+ "study_design_contract_sha256": str((contract or {}).get("contract_sha256") or ""),
371
+ }
372
+
373
+
374
+ def build_qualitative_contract(topic: str, keywords: Any, metadata: dict[str, Any], *, policy_version: str = "adaptive-qualitative-v1") -> dict[str, Any]:
375
+ meta = dict(metadata or {})
376
+ facts = dict(meta.get("study_facts") or {})
377
+ sources = []
378
+ for index, raw in enumerate(facts.get("data_sources") or [], start=1):
379
+ label = _clean(raw, 120)
380
+ folded = label.casefold()
381
+ family = (
382
+ "interview" if "interview" in folded else
383
+ "focus_group" if "focus" in folded and "group" in folded else
384
+ "observation" if "observ" in folded else
385
+ "log" if "log" in folded else
386
+ "document" if any(x in folded for x in ("document","archiv","record")) else
387
+ "digital_trace" if any(x in folded for x in ("digital trace","clickstream","platform trace")) else
388
+ "field_note" if "field note" in folded else
389
+ "artifact" if "artifact" in folded else "other"
390
+ )
391
+ sources.append({"source_id":f"D{index:03d}","label":label,"family":family,"semantic_descriptor":label if family=="other" else ""})
392
+ actors = []
393
+ seen = set()
394
+ for row in facts.get("data_source_roles") or []:
395
+ if not isinstance(row, dict): continue
396
+ label = _clean(row.get("actor_role"), 100)
397
+ if label and label.casefold() not in seen:
398
+ actors.append({"actor_id":f"A{len(actors)+1:03d}","label":label}); seen.add(label.casefold())
399
+ themes = []
400
+ for item in meta.get("themes") or []:
401
+ if not isinstance(item, dict): continue
402
+ themes.append({
403
+ "theme_id": _clean(item.get("theme_id"), 40), "title": _clean(item.get("title"), 160),
404
+ "stages": [_clean(x,80) for x in (item.get("stages") or []) if _clean(x,80)],
405
+ "has_deviant_evidence": bool(item.get("has_deviant_evidence")),
406
+ })
407
+ design = _clean(facts.get("analytic_design") or facts.get("analysis_method"), 120)
408
+ temporal = _clean(facts.get("temporal_structure"), 160)
409
+ case_structure = _clean(facts.get("case_structure"), 160)
410
+ contract = {
411
+ "version": STUDY_DESIGN_CONTRACT_VERSION,
412
+ "mode": "qualitative",
413
+ "source_type": _clean(meta.get("source_type"),40),
414
+ "source_topic": _clean(topic,500),
415
+ "source_keywords": _keywords(keywords),
416
+ "research_problem": "",
417
+ "research_questions": [],
418
+ "design_family": design or "Iterative thematic/process analysis",
419
+ "evidence_strength": {"causal_identification":False,"allowed_inference":["theme","process_pattern","interpretive_mechanism"],"forbidden_inference":["causal_effect"]},
420
+ "context": {"research_setting": _clean(facts.get("research_setting"),240)},
421
+ "quantitative": None,
422
+ "qualitative": {
423
+ "phenomenon": _clean(topic,300),
424
+ "organizational_puzzle": "",
425
+ "research_questions": [],
426
+ "design_tradition": design or "Iterative thematic/process analysis",
427
+ "units_of_analysis": [{"unit_id":"U001","label":case_structure or "study participants/organizational setting","level":"other"}],
428
+ "actor_groups": actors,
429
+ "data_sources": sources,
430
+ "sampling": {"participant_count": facts.get("participant_count"), "interview_count": facts.get("interview_count")},
431
+ "temporal_scope": {"description": temporal or "Not specified"},
432
+ "analysis_strategy": [design] if design else [],
433
+ "themes": themes,
434
+ "process_model": [],
435
+ "trustworthiness_procedures": list(facts.get("trustworthiness_procedures") or []),
436
+ "ethics_procedures": list(facts.get("ethics_procedures") or []),
437
+ "reflexivity_procedures": list(facts.get("reflexivity_procedures") or []),
438
+ },
439
+ "unsupported_seed_concepts": [],
440
+ "compiler_decisions": [],
441
+ "policy_version": policy_version,
442
+ }
443
+ return _finalize(contract)
444
+
445
+
446
+ def _study_owned_sentence(sentence: str) -> bool:
447
+ text = str(sentence or "")
448
+ if re.search(r"\bCITE-\d+\b", text, flags=re.I): return False
449
+ if re.search(r"\((?=[^()\n]*(?:19|20)\d{2})[^()\n]+\)", text): return False
450
+ if re.search(r"(?i)\b(?:prior|previous|existing|earlier)\s+(?:research|stud(?:y|ies)|literature)\b", text): return False
451
+ return bool(re.search(r"(?i)\b(?:we|our\s+study|this\s+study|the\s+present\s+study|the\s+study|our\s+analysis|our\s+results|our\s+findings|we\s+(?:examine|investigate|test|analy[sz]e|explore|study))\b", text))
452
+
453
+
454
+ def inspect_study_design_scope(text: str, contract: dict[str, Any], *, section_name: str = "") -> list[dict[str, Any]]:
455
+ """High-confidence whole-manuscript scope guard for both modes."""
456
+ value = str(text or "")
457
+ rows: list[dict[str, Any]] = []
458
+ mode = str((contract or {}).get("mode") or "")
459
+ sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+|\n+", value) if s.strip()]
460
+ if mode == "quantitative":
461
+ quant = dict((contract or {}).get("quantitative") or {})
462
+ constructs = [x for x in (quant.get("constructs") or []) if isinstance(x, dict)]
463
+ allowed_labels = {str(x.get("label") or "").casefold() for x in constructs if x.get("label")}
464
+ has_moderation = any(r.get("type") == "moderation" for r in (quant.get("relations") or []) if isinstance(r, dict))
465
+ has_indirect = bool(quant.get("indirect_effects"))
466
+ unsupported = [str(x) for x in (contract or {}).get("unsupported_seed_concepts") or [] if str(x).strip()]
467
+ for sentence in sentences:
468
+ if not _study_owned_sentence(sentence): continue
469
+ folded = sentence.casefold()
470
+ if re.search(r"\bmoderat(?:e|es|ed|ing|or|ion)\b|\binteraction\s+(?:effect|term)\b", folded) and not has_moderation:
471
+ rows.append({"type":"unsupported_moderation_claim","preview":sentence[:260],"reason":"The frozen study-design graph contains no moderation/interaction analysis target."})
472
+ if re.search(r"\bmediat(?:e|es|ed|ing|or|ion)\b|\bindirect\s+(?:effect|association)\b", folded) and not has_indirect:
473
+ rows.append({"type":"unsupported_mediation_claim","preview":sentence[:260],"reason":"The frozen study-design graph contains no indirect-effect analysis target."})
474
+ for concept in unsupported:
475
+ if concept.casefold() in folded and re.search(r"(?i)\b(?:examine|investigate|test|model|measure|assess|estimate)\b", sentence):
476
+ rows.append({"type":"unsupported_focal_construct","construct":concept,"preview":sentence[:260],"reason":"The concept is background-only under the frozen study-design contract."})
477
+ elif mode == "qualitative":
478
+ qual = dict((contract or {}).get("qualitative") or {})
479
+ design = str(qual.get("design_tradition") or "").casefold()
480
+ temporal = str(dict(qual.get("temporal_scope") or {}).get("description") or "").casefold()
481
+ families = {str(x.get("family") or "") for x in (qual.get("data_sources") or []) if isinstance(x, dict)}
482
+ unit_label = " ".join(str(x.get("label") or "") for x in (qual.get("units_of_analysis") or []) if isinstance(x, dict)).casefold()
483
+ for sentence in sentences:
484
+ if not _study_owned_sentence(sentence): continue
485
+ folded = sentence.casefold()
486
+ if "longitudinal" in folded and "longitudinal" not in temporal and "longitudinal" not in design:
487
+ rows.append({"type":"unsupported_temporal_scope","preview":sentence[:260],"reason":"The frozen qualitative design does not establish longitudinal evidence."})
488
+ if "ethnograph" in folded and "ethnograph" not in design:
489
+ rows.append({"type":"unsupported_design_tradition","preview":sentence[:260],"reason":"Ethnography is not the frozen qualitative design tradition."})
490
+ if "grounded theory" in folded and "grounded theory" not in design:
491
+ rows.append({"type":"unsupported_design_tradition","preview":sentence[:260],"reason":"Grounded theory is not the frozen qualitative design tradition."})
492
+ if "triangulat" in folded and len({x for x in families if x}) < 2:
493
+ rows.append({"type":"unsupported_data_source_claim","preview":sentence[:260],"reason":"Triangulation requires at least two frozen evidence-source families."})
494
+ if re.search(r"\b(?:multi|multiple)[- ]case\b|\bcomparative case\b", folded) and not any(x in (design + " " + unit_label) for x in ("comparative", "multiple", "multi-case")):
495
+ rows.append({"type":"unsupported_unit_of_analysis","preview":sentence[:260],"reason":"The frozen qualitative contract does not establish a multi-case comparison."})
496
+ return rows[:8]