SmartPaper / core /study_design_contract.py
GuXiaopeng's picture
L2-R1 Unicode keyword authority and upstream quantitative contract repair (#132)
99eb398
Raw History Blame Contribute Delete
58.1 kB
# -*- coding: utf-8 -*-
"""Adaptive study-design contracts shared by SmartPaper quantitative/qualitative paths.
The LLM may propose a candidate design, but this module owns deterministic
normalization, identity, validation, and scope semantics. It deliberately
contains no provider/model routing logic.
"""
from __future__ import annotations
import hashlib
import json
import re
from typing import Any
STUDY_DESIGN_CONTRACT_VERSION = "smartpaper-adaptive-study-design-contract-v1"
_QUANT_ROLES = {"predictor", "mediator", "moderator", "outcome", "control", "covariate"}
_QUANT_RELATIONS = {"direct_association", "moderation", "control_effect"}
_QUAL_SOURCE_FAMILIES = {
"interview", "focus_group", "observation", "document", "log",
"artifact", "digital_trace", "field_note", "other",
}
def _clean(value: Any, maximum: int = 240) -> str:
text = re.sub(r"\s+", " ", str(value or "")).strip(" \t\r\n,;:.-")
return text[:maximum]
KEYWORD_POLICY_VERSION = "smartpaper-keyword-policy-v1"
KEYWORD_SEPARATOR_POLICY = "UNICODE_SEMICOLON_NEWLINE_PIPE_PRIMARY_COMMA_FALLBACK"
_KEYWORD_COUNT_MODES = {"UNSPECIFIED", "PREFERRED", "REQUIRED"}
_KEYWORD_AUTHORITY_SOURCES = {"USER_EXPLICIT", "SYSTEM_GENERATED", "LEGACY"}
def parse_keyword_terms(value: Any, *, explicit_user: bool = False, diagnostics: list[dict[str, Any]] | None = None) -> list[str]:
"""Parse keyword identities without imposing an academic count limit.
Explicit user text uses semicolon/newline/pipe as primary separators so an
internal comma can remain part of a deliberate keyword phrase. Comma is
used only as a compatibility fallback when no primary separator is present.
List/tuple input is already structurally separated and is never split again.
"""
if isinstance(value, (list, tuple)):
raw = list(value)
else:
text = str(value or "")
if explicit_user and re.search(r"[;;\n|]", text):
raw = re.split(r"[;;\n|]+", text)
else:
raw = re.split(r"[,,;;\n|]+", text)
out: list[str] = []
seen: set[str] = set()
for item in raw:
term = re.sub(r"\s+", " ", str(item or "")).strip()
key = term.casefold()
if not term:
continue
if key in seen:
if diagnostics is not None:
diagnostics.append({"type": "keyword_duplicate_deduped", "keyword": term})
continue
out.append(term)
seen.add(key)
return out
def resolve_keyword_policy(
user_keywords: Any = None,
*,
authority_source: str = "",
configured_policy: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Resolve one versioned keyword policy without journal-count hardcoding.
Numeric preferences/requirements are accepted only when supplied by an
external/versioned policy record. AMJ/ASQ style labels do not imply a count.
Generation never fails merely because a count is outside a preference.
"""
user_terms = parse_keyword_terms(user_keywords, explicit_user=True)
source = str(authority_source or "").strip().upper()
if source not in _KEYWORD_AUTHORITY_SOURCES:
source = "USER_EXPLICIT" if user_terms else "SYSTEM_GENERATED"
configured = dict(configured_policy or {})
count_mode = str(configured.get("count_mode") or "UNSPECIFIED").strip().upper()
if count_mode not in _KEYWORD_COUNT_MODES:
count_mode = "UNSPECIFIED"
def _optional_nonnegative_int(name: str):
value = configured.get(name)
if value in (None, ""):
return None
try:
value = int(value)
except (TypeError, ValueError):
return None
return value if value >= 0 else None
preferred_min = _optional_nonnegative_int("preferred_min")
preferred_max = _optional_nonnegative_int("preferred_max")
required_min = _optional_nonnegative_int("required_min")
required_max = _optional_nonnegative_int("required_max")
if count_mode == "UNSPECIFIED":
preferred_min = preferred_max = required_min = required_max = None
elif count_mode == "PREFERRED":
required_min = required_max = None
elif count_mode == "REQUIRED":
preferred_min = preferred_max = None
return {
"version": KEYWORD_POLICY_VERSION,
"authority_source": source,
"identity_policy": "PRESERVE_USER_IDENTITIES" if source == "USER_EXPLICIT" else "NORMALIZE_DISTINCT_IDENTITIES",
"separator_policy": KEYWORD_SEPARATOR_POLICY,
"duplicate_policy": "DEDUPE_EXACT_WITH_DIAGNOSTIC",
"journal_requirement_source": str(configured.get("journal_requirement_source") or "").strip(),
"count_mode": count_mode,
"preferred_min": preferred_min,
"preferred_max": preferred_max,
"required_min": required_min,
"required_max": required_max,
"count_failure_mode": "EXPORT_COMPLIANCE" if count_mode == "REQUIRED" else "ADVISORY",
"fallback_source": "FROZEN_STUDY_DESIGN_CONTRACT",
"target_language_policy": "TRANSLATE_IDENTITY_SET_WITHOUT_RESIZE",
}
def keyword_policy_from_contract(contract: dict[str, Any] | None, *, fallback_keywords: Any = None) -> dict[str, Any]:
record = dict(contract or {})
intent = dict(record.get("user_research_intent") or {})
user_terms = list(intent.get("source_keywords") or [])
configured = dict(record.get("keyword_policy") or intent.get("keyword_policy") or {})
authority_source = str(configured.get("authority_source") or "").strip()
if not user_terms and not authority_source:
# Legacy serialized contracts may have source_keywords but no explicit
# R5/R6 user-authority marker; do not silently promote them to user input.
return resolve_keyword_policy(None, authority_source="LEGACY", configured_policy=configured)
return resolve_keyword_policy(
user_terms if user_terms else fallback_keywords,
authority_source=authority_source,
configured_policy=configured,
)
def evaluate_keyword_count(keywords: Any, policy: dict[str, Any] | None) -> dict[str, Any]:
"""Return advisory/compliance status; never decides manuscript completion."""
terms = parse_keyword_terms(keywords, explicit_user=False)
resolved = dict(policy or resolve_keyword_policy())
mode = str(resolved.get("count_mode") or "UNSPECIFIED").upper()
count = len(terms)
minimum = resolved.get("preferred_min") if mode == "PREFERRED" else resolved.get("required_min")
maximum = resolved.get("preferred_max") if mode == "PREFERRED" else resolved.get("required_max")
within = True
if minimum is not None and count < int(minimum):
within = False
if maximum is not None and count > int(maximum):
within = False
return {
"count": count,
"count_mode": mode,
"within_policy_range": within,
"minimum": minimum,
"maximum": maximum,
"blocking_generation": False,
"failure_mode": str(resolved.get("count_failure_mode") or "ADVISORY"),
}
def apply_keyword_policy(
value: Any,
policy: dict[str, Any] | None,
*,
authoritative: Any = None,
fallback: Any = None,
) -> list[str]:
resolved = dict(policy or resolve_keyword_policy(authoritative))
source = str(resolved.get("authority_source") or "SYSTEM_GENERATED").upper()
if source == "USER_EXPLICIT":
terms = parse_keyword_terms(authoritative, explicit_user=True)
if terms:
return terms
terms = parse_keyword_terms(value, explicit_user=False)
if not terms and fallback is not None:
terms = parse_keyword_terms(fallback, explicit_user=False)
return terms
def _keywords(value: Any) -> list[str]:
# Internal non-user lists retain bounded metadata normalization. This is not
# a reader-facing front-matter academic cardinality rule.
out: list[str] = []
seen: set[str] = set()
for item in parse_keyword_terms(value, explicit_user=False):
text = _clean(item, 100)
key = text.casefold()
if text and key not in seen:
out.append(text)
seen.add(key)
return out[:16]
USER_RESEARCH_INTENT_VERSION = "smartpaper-user-research-intent-v1"
def _verbatim_keywords(value: Any) -> list[str]:
"""Preserve explicit user keyword identity/order under the resolved separator policy."""
return parse_keyword_terms(value, explicit_user=True)
_MODERATION_CUES = (
r"\bmoderating\s+role\b", r"\bmoderation\b", r"\bmoderator\b", r"\bmoderates?\b",
r"调节作用", r"调节效应", r"调节变量", r"调节关系",
r"модерирующ", r"модератор", r"модерац",
r"модераторлық", r"модерациялық", r"модератор ретінде",
)
_MEDIATION_CUES = (
r"\bmediating\s+role\b", r"\bmediation\b", r"\bmediator\b", r"\bmediates?\b",
r"中介作用", r"中介效应", r"中介变量", r"链式中介", r"并行中介",
r"медиирующ", r"медиатор", r"медиац",
r"медиаторлық", r"медиациялық", r"медиатор ретінде",
)
_SERIAL_MEDIATION_CUES = (
r"\bserial\s+mediat", r"\bsequential\s+mediat", r"链式中介", r"序列中介",
r"последовательн.{0,16}медиац", r"тізбекті.{0,16}медиац",
)
_PARALLEL_MEDIATION_CUES = (
r"\bparallel\s+mediat", r"并行中介", r"параллельн.{0,16}медиац", r"параллель.{0,16}медиац",
)
_QUAL_DESIGN_PATTERNS = (
("Grounded theory", (r"\bgrounded\s+theory\b", r"扎根理论", r"обоснованн.{0,8}теори", r"негізделген\s+теори")),
("Gioia-style inductive coding", (r"\bgioia\b", r"Gioia方法", r"Gioia 方法", r"джойя", r"гиоиа")),
("Comparative case analysis", (r"\bcomparative\s+(?:case|cases)\b", r"比较案例", r"比较个案", r"сравнительн.{0,10}(?:кейс|случа)", r"салыстырмалы.{0,10}(?:кейс|жағдай)")),
("Longitudinal qualitative analysis", (r"\blongitudinal\b", r"纵向(?:定性|质性)?", r"продольн", r"лонгитюд", r"бойлық")),
("Process analysis", (r"\bprocess\s+analysis\b", r"过程分析", r"процессн.{0,8}анализ", r"үдерістік.{0,8}талдау", r"процестік.{0,8}талдау")),
("Iterative thematic/process analysis", (r"\bthematic\s+analysis\b", r"主题分析", r"тематическ.{0,8}анализ", r"тақырыптық.{0,8}талдау")),
)
_QUAL_SOURCE_PATTERNS = (
("interview", (r"\bsemi[- ]structured\s+interview", r"\binterviews?\b", r"半结构式访谈", r"半结构访谈", r"访谈", r"полуструктурированн.{0,8}интервью", r"интервью", r"жартылай құрылымданған.{0,8}сұхбат", r"сұхбат")),
("focus_group", (r"\bfocus\s+groups?\b", r"焦点小组", r"фокус[- ]?групп", r"фокус[- ]?топ")),
("observation", (r"\bobservation(?:s)?\b", r"观察", r"наблюден", r"бақылау")),
("document", (r"\barchival\b", r"\bdocument(?:s|ary)?\b", r"档案", r"文档", r"文件分析", r"архив", r"документ", r"мұрағат", r"құжат")),
("field_note", (r"\bfield\s+notes?\b", r"田野笔记", r"полев.{0,6}замет", r"далалық.{0,6}жазб")),
("digital_trace", (r"\bdigital\s+traces?\b", r"数字痕迹", r"цифров.{0,6}след", r"цифрлық.{0,6}із")),
("log", (r"\blogs?\b", r"日志", r"журнал.{0,8}лог", r"лог", r"журнал")),
)
def _contains_any(text: str, patterns: tuple[str, ...]) -> bool:
return any(re.search(pattern, text, flags=re.I | re.UNICODE) for pattern in patterns)
def build_user_research_intent(topic: str, keywords: Any, *, mode: str = "") -> dict[str, Any]:
"""Freeze user topic/keywords as research-intent authority, separate from literature evidence."""
source_topic = re.sub(r"\s+", " ", str(topic or "")).strip()
keyword_diagnostics: list[dict[str, Any]] = []
source_keywords = parse_keyword_terms(keywords, explicit_user=True, diagnostics=keyword_diagnostics)
keyword_policy = resolve_keyword_policy(source_keywords)
text = " ; ".join([source_topic] + source_keywords)
relation_types: list[str] = []
if _contains_any(text, _MODERATION_CUES):
relation_types.append("moderation")
if _contains_any(text, _MEDIATION_CUES):
relation_types.append("mediation")
if _contains_any(text, _SERIAL_MEDIATION_CUES):
relation_types.append("serial_mediation")
if _contains_any(text, _PARALLEL_MEDIATION_CUES):
relation_types.append("parallel_mediation")
qualitative_design = ""
for canonical, patterns in _QUAL_DESIGN_PATTERNS:
if _contains_any(text, patterns):
qualitative_design = canonical
break
source_families: list[str] = []
for family, patterns in _QUAL_SOURCE_PATTERNS:
if _contains_any(text, patterns):
source_families.append(family)
return {
"version": USER_RESEARCH_INTENT_VERSION,
"mode": str(mode or "").strip().casefold(),
"source_topic": source_topic,
"source_keywords": source_keywords,
"keyword_policy": keyword_policy,
"keyword_diagnostics": keyword_diagnostics,
"explicit_relation_types": relation_types,
"qualitative_design": qualitative_design,
"qualitative_data_source_families": source_families,
"preservation_policy": "user_topic_keywords_and_explicit_design_are_authoritative;literature_cannot_silently_redefine_scope",
"evidence_policy": "verified_sources_support_prior_literature;author_inference_may_develop_novel_logic_without_fake_citations",
}
def _cue_positions(text: str, patterns: tuple[str, ...]) -> list[int]:
positions: list[int] = []
for pattern in patterns:
positions.extend(match.start() for match in re.finditer(pattern, text, flags=re.I | re.UNICODE))
return positions
def _phrase_bound_intent_label(text: str, labels: list[str], relation: str) -> str:
"""Resolve an explicitly named mediator/moderator from multilingual cue grammar."""
for label in labels:
clean = re.sub(r"\s+", " ", str(label or "")).strip()
if not clean:
continue
esc = re.escape(clean)
if relation == "moderation":
patterns = (
rf"(?i)\bmoderating\s+role\s+of\s+(?:the\s+)?{esc}\b",
rf"(?i)\bmoderation(?:\s+effect)?\s+of\s+(?:the\s+)?{esc}\b",
rf"(?i)\b{esc}\b.{0,24}\b(?:as\s+(?:a\s+)?)?moderator\b",
rf"{esc}(?:的)?调节(?:作用|效应|变量|关系)?",
rf"(?i)модерирующ\w*\s+роль\s+(?:\S+\s+){{0,3}}?{esc}",
rf"(?i){esc}.{{0,12}}модератор ретінде",
)
else:
patterns = (
rf"(?i)\bmediating\s+role\s+of\s+(?:the\s+)?{esc}\b",
rf"(?i)\bmediation(?:\s+effect)?\s+of\s+(?:the\s+)?{esc}\b",
rf"(?i)\b{esc}\b.{0,24}\b(?:as\s+(?:a\s+)?)?mediator\b",
rf"{esc}(?:的)?(?:链式|并行)?中介(?:作用|效应|变量)?",
rf"(?i)медиирующ\w*\s+роль\s+(?:\S+\s+){{0,3}}?{esc}",
rf"(?i){esc}.{{0,12}}медиатор ретінде",
)
if any(re.search(pattern, text, flags=re.UNICODE) for pattern in patterns):
return clean
return ""
def _closest_intent_label(text: str, labels: list[str], cue_patterns: tuple[str, ...]) -> str:
"""Choose the user-mentioned label nearest an explicit relation cue; never invent a label."""
folded = text.casefold()
cue_pos = _cue_positions(text, cue_patterns)
if not cue_pos:
return ""
scored: list[tuple[int, int, str]] = []
for order, label in enumerate(labels):
clean = re.sub(r"\s+", " ", str(label or "")).strip()
if not clean:
continue
start = folded.find(clean.casefold())
if start < 0:
continue
midpoint = start + max(1, len(clean) // 2)
distance = min(abs(midpoint - pos) for pos in cue_pos)
scored.append((distance, order, clean))
return min(scored)[2] if scored else ""
def _ensure_construct_role(constructs: list[dict[str, Any]], label: str, role: str) -> str:
if not label:
return ""
for row in constructs:
if str(row.get("label") or "").casefold() == label.casefold():
roles = list(row.get("roles") or [])
if role not in roles:
roles = [r for r in roles if not (role in {"mediator", "moderator"} and r in {"control", "covariate"})]
roles.append(role)
row["roles"] = roles
return str(row.get("construct_id") or "")
cid = f"C{len(constructs) + 1:03d}"
constructs.append({
"construct_id": cid,
"label": label,
"roles": [role],
"measurement_model": "reflective",
"measurement_allowed": True,
})
return cid
def _next_hypothesis_order(relations: list[dict[str, Any]]) -> int:
values = []
for row in relations:
try:
if row.get("hypothesis_order") is not None:
values.append(int(row.get("hypothesis_order")))
except Exception:
pass
numbered_count = sum(1 for row in relations if row.get("numbered_hypothesis", True) and row.get("type") != "control_effect")
return max(max(values) if values else 0, numbered_count) + 1
def _append_relation(relations: list[dict[str, Any]], *, rtype: str, source: str, target: str, moderator: str = "") -> None:
if not source or not target or source == target:
return
for row in relations:
if row.get("type") != rtype or row.get("source") != source or row.get("target") != target:
continue
if rtype != "moderation" or row.get("moderator") == moderator:
return
relations.append({
"relation_id": f"R{len(relations) + 1:03d}",
"type": rtype,
"source": source,
"target": target,
"moderator": moderator or None,
"analysis_target": True,
"numbered_hypothesis": rtype != "control_effect",
"hypothesis_order": _next_hypothesis_order(relations),
})
def _reconcile_explicit_quantitative_intent(
*, topic: str, keywords: Any, constructs: list[dict[str, Any]], relations: list[dict[str, Any]], decisions: list[dict[str, Any]],
source_topic: str | None = None, source_keywords: Any = None,
) -> dict[str, Any]:
"""Make explicit user design intent canonical rather than merely additive.
R7-R2C-R5 restored omitted mediation/moderation relations, but an upstream
model could still leave contradictory relations in place. S1-R1 closes
that authority gap: when the user explicitly names a moderator, model-
assigned alternative moderators are advisory proposals and are removed
before the canonical user relation is compiled.
"""
source_intent = build_user_research_intent(
topic if source_topic is None else source_topic,
keywords if source_keywords is None else source_keywords,
mode="quantitative",
)
identity_intent = build_user_research_intent(topic, keywords, mode="quantitative")
explicit = set(source_intent.get("explicit_relation_types") or [])
if not explicit:
return source_intent
# Construct/hypothesis identity is compiled from the validated English
# projection, while the immutable source-language user intent remains the
# semantic authority recorded in the contract.
intent_text = " ; ".join(
[identity_intent.get("source_topic") or ""] + list(identity_intent.get("source_keywords") or [])
)
labels = [str(row.get("label") or "") for row in constructs]
labels += [
kw for kw in (identity_intent.get("source_keywords") or [])
if kw.casefold() not in {x.casefold() for x in labels}
]
def ids_for(role: str) -> list[str]:
return [str(row.get("construct_id") or "") for row in constructs if role in (row.get("roles") or [])]
if "moderation" in explicit:
moderator_ids = ids_for("moderator")
# A phrase-bound moderator named by the user is canonical authority.
# Model-assigned moderator roles/relations may not coexist when they
# contradict that explicit identity.
moderator_label = _phrase_bound_intent_label(intent_text, labels, "moderation")
authoritative_named_moderator = bool(moderator_label)
if not moderator_label:
noncore_labels = [
str(row.get("label") or "") for row in constructs
if not set(row.get("roles") or []).intersection({"predictor", "mediator", "outcome"})
]
if len([label for label in noncore_labels if label]) == 1:
moderator_label = next(label for label in noncore_labels if label)
if not moderator_label:
moderator_label = _closest_intent_label(intent_text, labels, _MODERATION_CUES)
if moderator_label:
moderator_id = _ensure_construct_role(constructs, moderator_label, "moderator")
elif len(moderator_ids) == 1:
moderator_id = moderator_ids[0]
else:
moderator_id = ""
if moderator_id:
if authoritative_named_moderator:
# Remove only the conflicting moderator role. Other legitimate
# roles (e.g. predictor/main-effect participation) are preserved.
for row in constructs:
if str(row.get("construct_id") or "") == moderator_id:
continue
roles = list(row.get("roles") or [])
if "moderator" in roles:
row["roles"] = [role for role in roles if role != "moderator"]
removed = []
retained = []
for relation in relations:
if relation.get("type") == "moderation" and str(relation.get("moderator") or "") != moderator_id:
removed.append(dict(relation))
continue
retained.append(relation)
if removed:
relations[:] = retained
decisions.append({
"reason": "conflicting_model_moderation_removed_by_explicit_user_authority",
"moderator": moderator_id,
"removed_relation_ids": [str(row.get("relation_id") or "") for row in removed],
})
direct = [r for r in relations if r.get("type") == "direct_association"]
predictor_ids, outcome_ids = ids_for("predictor"), ids_for("outcome")
preferred = next((r for r in direct if r.get("source") in predictor_ids and r.get("target") in outcome_ids), None)
anchor = preferred or (direct[0] if direct else None)
source = str((anchor or {}).get("source") or (predictor_ids[0] if predictor_ids else ""))
target = str((anchor or {}).get("target") or (outcome_ids[0] if outcome_ids else ""))
before = len(relations)
_append_relation(relations, rtype="moderation", source=source, target=target, moderator=moderator_id)
if len(relations) > before:
decisions.append({"reason":"explicit_user_moderation_reconciled","moderator":moderator_id,"source":source,"target":target})
if authoritative_named_moderator:
# After removing contradictory model hypotheses, close any H-number
# gap deterministically. This is identity normalization only; it
# does not change relation semantics or create extra hypotheses.
numbered = [
(idx, row) for idx, row in enumerate(relations)
if row.get("numbered_hypothesis", True) and row.get("type") != "control_effect"
]
numbered.sort(key=lambda item: (
int(item[1].get("hypothesis_order")) if str(item[1].get("hypothesis_order") or "").isdigit() else 10**6,
item[0],
))
for order, (_idx, row) in enumerate(numbered, start=1):
row["hypothesis_order"] = order
if "mediation" in explicit or "serial_mediation" in explicit or "parallel_mediation" in explicit:
mediator_ids = ids_for("mediator")
mediator_label = _phrase_bound_intent_label(intent_text, labels, "mediation") or _closest_intent_label(intent_text, labels, _MEDIATION_CUES)
if mediator_label:
mediator_id = _ensure_construct_role(constructs, mediator_label, "mediator")
mediator_ids = [mediator_id] + [mid for mid in mediator_ids if mid != mediator_id]
elif not mediator_ids:
mediator_ids = []
predictor_ids, outcome_ids = ids_for("predictor"), ids_for("outcome")
if predictor_ids and outcome_ids and mediator_ids:
p, o = predictor_ids[0], outcome_ids[0]
if "serial_mediation" in explicit and len(mediator_ids) > 1:
path = [p] + mediator_ids + [o]
for a, b in zip(path, path[1:]):
before = len(relations); _append_relation(relations, rtype="direct_association", source=a, target=b)
if len(relations) > before:
decisions.append({"reason":"explicit_user_serial_mediation_edge_reconciled","source":a,"target":b})
else:
for mediator_id in mediator_ids:
for a, b in ((p, mediator_id), (mediator_id, o)):
before = len(relations); _append_relation(relations, rtype="direct_association", source=a, target=b)
if len(relations) > before:
decisions.append({"reason":"explicit_user_mediation_edge_reconciled","source":a,"target":b})
return source_intent
def _stable_hash(payload: dict[str, Any]) -> str:
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _finalize(contract: dict[str, Any]) -> dict[str, Any]:
value = dict(contract)
value.pop("contract_sha256", None)
value.pop("contract_id", None)
digest = _stable_hash(value)
value["contract_id"] = digest[:20]
value["contract_sha256"] = digest
return value
def _normalize_quant_relation_type(value: Any) -> str:
text = _clean(value, 80).casefold().replace("-", "_").replace(" ", "_")
aliases = {
"association": "direct_association", "direct": "direct_association",
"direct_effect": "direct_association", "direct_association": "direct_association",
"path": "direct_association", "mediation_path": "direct_association",
"moderator": "moderation", "moderation": "moderation", "interaction": "moderation",
"interaction_effect": "moderation", "control": "control_effect", "control_effect": "control_effect",
}
return aliases.get(text, text)
def _normalize_roles(value: Any) -> list[str]:
raw = value if isinstance(value, (list, tuple)) else [value]
out: list[str] = []
for item in raw:
role = _clean(item, 40).casefold().replace(" ", "_")
if role in _QUANT_ROLES and role not in out:
out.append(role)
return out
def _legacy_quant_candidate(constructs: list[str], context: str = "") -> dict[str, Any]:
values = [_clean(item, 80) for item in constructs if _clean(item, 80)]
if len(values) != 3:
values = ["Focal Predictor", "Focal Mediator", "Focal Outcome"]
p, m, o = values
return {
"constructs": [
{"label": p, "roles": ["predictor"]},
{"label": m, "roles": ["mediator"]},
{"label": o, "roles": ["outcome"]},
],
"relations": [
{"source": p, "target": o, "type": "direct_association", "hypothesis_order": 1},
{"source": p, "target": m, "type": "direct_association", "hypothesis_order": 2},
{"source": m, "target": o, "type": "direct_association", "hypothesis_order": 3},
],
"indirect_effects": [{"path": [p, m, o], "kind": "simple", "numbered_hypothesis": False}],
"research_context": context,
"background_concepts": [],
}
def build_quantitative_contract(
topic: str,
keywords: Any,
candidate: Any,
*,
source_type: str = "system_generated",
policy_version: str = "adaptive-quantitative-v1",
source_topic: str | None = None,
source_keywords: Any = None,
) -> dict[str, Any]:
"""Normalize a model-proposed quantitative graph into deterministic authority."""
raw = dict(candidate or {}) if isinstance(candidate, dict) else {}
raw_constructs = raw.get("constructs") or []
if raw_constructs and all(not isinstance(item, dict) for item in raw_constructs):
raw = _legacy_quant_candidate(list(raw_constructs), _clean(raw.get("research_context")))
raw_constructs = raw["constructs"]
has_explicit_source_authority = source_topic is not None or source_keywords is not None
source_topic_value = topic if source_topic is None else source_topic
source_keywords_value = keywords if source_keywords is None else source_keywords
source_terms = parse_keyword_terms(source_keywords_value, explicit_user=True)
identity_terms = parse_keyword_terms(keywords, explicit_user=True)
identity_aliases: dict[str, str] = {}
if has_explicit_source_authority and source_terms and len(source_terms) == len(identity_terms):
identity_aliases = {
str(source).casefold(): str(target)
for source, target in zip(source_terms, identity_terms)
if str(source).strip() and str(target).strip()
}
def canonical_identity(value: Any) -> str:
label = _clean(value, 80)
return identity_aliases.get(label.casefold(), label)
constructs: list[dict[str, Any]] = []
label_to_id: dict[str, str] = {}
rejected: list[dict[str, Any]] = []
for item in raw_constructs[:8]:
if not isinstance(item, dict):
continue
label = canonical_identity(item.get("label") or item.get("name"))
# A construct label containing a primary keyword separator is a malformed
# keyword blob, not a legitimate latent-variable identity. Reject it
# before relations/statistics can be compiled from it.
if re.search(r"[;;|\n]", label):
rejected.append({"reason": "keyword_blob_construct_rejected", "label": label})
continue
if not label or label.casefold() in label_to_id:
continue
roles = _normalize_roles(item.get("roles") or item.get("role"))
if not roles:
continue
cid = f"C{len(constructs) + 1:03d}"
row = {
"construct_id": cid,
"label": label,
"roles": roles,
"measurement_model": _clean(item.get("measurement_model") or "reflective", 40).casefold() or "reflective",
"measurement_allowed": bool(item.get("measurement_allowed", True)),
}
constructs.append(row)
label_to_id[label.casefold()] = cid
# Safe compatibility fallback; importantly this is a fallback, not the normal schema.
if len(constructs) < 2:
raw = _legacy_quant_candidate(["Focal Predictor", "Focal Mediator", "Focal Outcome"], _clean(raw.get("research_context")))
return build_quantitative_contract(
topic, keywords, raw, source_type=source_type, policy_version=policy_version,
source_topic=source_topic_value, source_keywords=source_keywords_value,
)
by_id = {row["construct_id"]: row for row in constructs}
def resolve(ref: Any) -> str:
text = canonical_identity(ref)
if text in by_id:
return text
return label_to_id.get(text.casefold(), "")
relations: list[dict[str, Any]] = []
for item in (raw.get("relations") or [])[:24]:
if not isinstance(item, dict):
continue
rtype = _normalize_quant_relation_type(item.get("type"))
source = resolve(item.get("source"))
target = resolve(item.get("target"))
moderator = resolve(item.get("moderator")) if item.get("moderator") else ""
if rtype not in _QUANT_RELATIONS or not source or not target or source == target:
rejected.append({"reason": "invalid_or_unsupported_relation", "raw": {k: item.get(k) for k in ("type", "source", "target", "moderator")}})
continue
if rtype == "moderation" and (not moderator or moderator in {source, target}):
rejected.append({"reason": "moderation_requires_distinct_moderator", "raw": {k: item.get(k) for k in ("type", "source", "target", "moderator")}})
continue
relation = {
"relation_id": f"R{len(relations) + 1:03d}",
"type": rtype,
"source": source,
"target": target,
"moderator": moderator or None,
"analysis_target": bool(item.get("analysis_target", True)),
"numbered_hypothesis": bool(item.get("numbered_hypothesis", rtype != "control_effect")),
}
try:
relation["hypothesis_order"] = int(item.get("hypothesis_order")) if item.get("hypothesis_order") is not None else None
except Exception:
relation["hypothesis_order"] = None
relations.append(relation)
# R7-R2C-R5: user topic/keywords are research-intent authority. Literature
# availability and an incomplete model-proposed JSON may not silently erase an
# explicitly requested mediation/moderation design. Reconciliation is
# deterministic and never invents literature evidence.
user_intent = _reconcile_explicit_quantitative_intent(
topic=topic, keywords=keywords, constructs=constructs, relations=relations, decisions=rejected,
source_topic=source_topic_value, source_keywords=source_keywords_value,
)
by_id = {row["construct_id"]: row for row in constructs}
label_to_id = {str(row.get("label") or "").casefold(): str(row.get("construct_id") or "") for row in constructs}
# If the interpreter omitted relations but roles make a parsimonious graph unambiguous,
# compile a safe role-derived graph rather than silently returning an empty model.
if not relations:
predictors = [x["construct_id"] for x in constructs if "predictor" in x["roles"]]
mediators = [x["construct_id"] for x in constructs if "mediator" in x["roles"]]
outcomes = [x["construct_id"] for x in constructs if "outcome" in x["roles"]]
moderators = [x["construct_id"] for x in constructs if "moderator" in x["roles"]]
if predictors and outcomes:
p, o = predictors[0], outcomes[0]
if mediators:
m = mediators[0]
relations.extend([
{"relation_id":"R001","type":"direct_association","source":p,"target":o,"moderator":None,"analysis_target":True,"numbered_hypothesis":True,"hypothesis_order":1},
{"relation_id":"R002","type":"direct_association","source":p,"target":m,"moderator":None,"analysis_target":True,"numbered_hypothesis":True,"hypothesis_order":2},
{"relation_id":"R003","type":"direct_association","source":m,"target":o,"moderator":None,"analysis_target":True,"numbered_hypothesis":True,"hypothesis_order":3},
])
else:
relations.append({"relation_id":"R001","type":"direct_association","source":p,"target":o,"moderator":None,"analysis_target":True,"numbered_hypothesis":True,"hypothesis_order":1})
if moderators:
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})
direct_pairs = {(r["source"], r["target"]): r["relation_id"] for r in relations if r["type"] == "direct_association"}
indirect_effects: list[dict[str, Any]] = []
raw_indirect = raw.get("indirect_effects") or []
for item in raw_indirect[:12]:
if not isinstance(item, dict):
continue
path = [resolve(x) for x in (item.get("path") or [])]
path = [x for x in path if x]
if len(path) < 3 or any((a, b) not in direct_pairs for a, b in zip(path, path[1:])):
rejected.append({"reason":"invalid_indirect_path","raw":{"path": item.get("path")}})
continue
indirect_effects.append({
"effect_id": f"I{len(indirect_effects)+1:03d}",
"path": path,
"kind": _clean(item.get("kind") or ("simple" if len(path)==3 else "serial"), 30).casefold(),
"numbered_hypothesis": bool(item.get("numbered_hypothesis", False)),
})
if not indirect_effects:
# Infer valid mediator chains from role identity and existing path edges.
predictors = [x["construct_id"] for x in constructs if "predictor" in x["roles"]]
mediators = [x["construct_id"] for x in constructs if "mediator" in x["roles"]]
outcomes = [x["construct_id"] for x in constructs if "outcome" in x["roles"]]
for p in predictors:
for m in mediators:
for o in outcomes:
if (p, m) in direct_pairs and (m, o) in direct_pairs:
indirect_effects.append({"effect_id":f"I{len(indirect_effects)+1:03d}","path":[p,m,o],"kind":"simple","numbered_hypothesis":False})
controls = [x["construct_id"] for x in constructs if set(x["roles"]) & {"control", "covariate"}]
outcomes = [x["construct_id"] for x in constructs if "outcome" in x["roles"]]
if not outcomes:
targeted = {r["target"] for r in relations if r["type"] != "control_effect"}
outcomes = sorted(targeted)
analysis_plan = ["measurement_assessment"]
if any(r["type"] == "direct_association" for r in relations): analysis_plan.append("direct_association")
if indirect_effects: analysis_plan.append("indirect_effect")
if any(r["type"] == "moderation" for r in relations): analysis_plan.extend(["interaction_test", "simple_slopes"])
analysis_plan.append("robustness")
# Legacy profile is used only to preserve exact prior H ordering and validators.
role_map = {role: [x for x in constructs if role in x["roles"]] for role in ("predictor","mediator","outcome")}
legacy_profile = (
len(constructs) == 3
and all(len(role_map[r]) == 1 for r in role_map)
and not any("moderator" in x["roles"] or set(x["roles"]) & {"control","covariate"} for x in constructs)
and len([r for r in relations if r["type"] == "direct_association"]) == 3
)
unsupported = _keywords(raw.get("background_concepts") or raw.get("unsupported_seed_concepts") or [])
context = _clean(raw.get("research_context") or "respondents with experience relevant to the focal management phenomenon", 240)
contract = {
"version": STUDY_DESIGN_CONTRACT_VERSION,
"mode": "quantitative",
"source_type": _clean(source_type, 40) or "system_generated",
"source_topic": str(user_intent.get("source_topic") or re.sub(r"\s+", " ", str(topic or "")).strip()),
"source_keywords": list(user_intent.get("source_keywords") or _verbatim_keywords(keywords)),
"user_research_intent": user_intent,
"keyword_policy": dict(user_intent.get("keyword_policy") or resolve_keyword_policy()),
"research_problem": _clean(raw.get("research_problem"), 500),
"research_questions": _keywords(raw.get("research_questions") or []),
"design_family": "cross_sectional_observational_survey",
"evidence_strength": {
"causal_identification": False,
"allowed_inference": ["association", "estimated_indirect_association", "interaction_association"],
"forbidden_inference": ["causal_effect", "causal_mediation_identification"],
},
"context": {"research_context": context},
"quantitative": {
"constructs": constructs,
"relations": relations,
"indirect_effects": indirect_effects,
"controls": controls,
"outcomes": outcomes,
"analysis_plan": analysis_plan,
"legacy_three_construct_profile": legacy_profile,
},
"qualitative": None,
"unsupported_seed_concepts": unsupported,
"compiler_decisions": rejected,
"policy_version": policy_version,
}
if has_explicit_source_authority:
contract["internal_english_authority"] = {
"version": "quantitative-internal-english-authority-v1",
"title": re.sub(r"\s+", " ", str(topic or "")).strip(),
"keywords": identity_terms,
"source_keyword_count": len(list(user_intent.get("source_keywords") or [])),
"translation_only": True,
}
return _finalize(contract)
def validate_quantitative_contract(contract: dict[str, Any]) -> list[str]:
issues: list[str] = []
record = dict(contract or {})
if record.get("version") != STUDY_DESIGN_CONTRACT_VERSION or record.get("mode") != "quantitative":
return ["invalid_contract_identity"]
quant = dict(record.get("quantitative") or {})
constructs = [x for x in (quant.get("constructs") or []) if isinstance(x, dict)]
ids = {x.get("construct_id") for x in constructs}
if len(ids) != len(constructs) or len(constructs) < 2:
issues.append("invalid_construct_identity")
relations = [r for r in (quant.get("relations") or []) if isinstance(r, dict)]
for r in quant.get("relations") or []:
if not isinstance(r, dict):
issues.append("invalid_relation_record"); continue
if r.get("source") not in ids or r.get("target") not in ids:
issues.append("relation_references_unknown_construct")
if r.get("type") == "moderation" and r.get("moderator") not in ids:
issues.append("moderation_references_unknown_moderator")
# Semantic completeness is part of contract validity: an internally legal graph
# is still invalid when it silently drops an explicit user-requested relation.
intent = dict(record.get("user_research_intent") or {})
internal_authority = dict(record.get("internal_english_authority") or {})
if internal_authority:
internal_title = str(internal_authority.get("title") or "")
internal_keywords = [str(x).strip() for x in (internal_authority.get("keywords") or []) if str(x).strip()]
source_terms = [str(x).strip() for x in (intent.get("source_keywords") or []) if str(x).strip()]
if source_terms and len(internal_keywords) != len(source_terms):
issues.append("internal_english_keyword_cardinality_mismatch")
foreign_script = re.compile(r"[\u3400-\u9fff\u3040-\u30ff\u0400-\u052f]")
if foreign_script.search(" ".join([internal_title] + internal_keywords)):
issues.append("internal_english_authority_language_contamination")
for row in constructs:
label = str(row.get("label") or "")
if re.search(r"[;;|\n]", label):
issues.append("quantitative_construct_keyword_blob")
if foreign_script.search(label):
issues.append("quantitative_construct_identity_language_contamination")
explicit = set(intent.get("explicit_relation_types") or [])
if "moderation" in explicit and not any(r.get("type") == "moderation" for r in relations):
issues.append("explicit_user_moderation_missing")
if explicit.intersection({"mediation", "serial_mediation", "parallel_mediation"}) and not (quant.get("indirect_effects") or []):
issues.append("explicit_user_mediation_missing")
if str(record.get("source_topic") or "") != str(intent.get("source_topic") or record.get("source_topic") or ""):
issues.append("user_topic_authority_drift")
return list(dict.fromkeys(issues))
def compile_quantitative_hypothesis_ledger(contract: dict[str, Any], *, version: str) -> dict[str, Any]:
quant = dict((contract or {}).get("quantitative") or {})
constructs = {x.get("construct_id"): x for x in (quant.get("constructs") or []) if isinstance(x, dict)}
rels = [dict(r) for r in (quant.get("relations") or []) if isinstance(r, dict) and r.get("numbered_hypothesis")]
rels.sort(key=lambda r: (r.get("hypothesis_order") is None, r.get("hypothesis_order") or 999, r.get("relation_id") or ""))
items: list[dict[str, Any]] = []
for r in rels:
source = constructs.get(r.get("source"), {}).get("label")
target = constructs.get(r.get("target"), {}).get("label")
moderator = constructs.get(r.get("moderator"), {}).get("label") if r.get("moderator") else None
if not source or not target:
continue
row = {
"hypothesis_id": f"H{len(items)+1}",
"predictor": source,
"outcome": target,
"relation": "moderation" if r.get("type") == "moderation" else "association",
"relation_id": r.get("relation_id"),
}
if moderator:
row["moderator"] = moderator
items.append(row)
indirect_rows = []
for effect in quant.get("indirect_effects") or []:
if not isinstance(effect, dict): continue
labels = [constructs.get(cid, {}).get("label") for cid in effect.get("path") or []]
labels = [x for x in labels if x]
if len(labels) >= 3:
indirect_rows.append({
"effect_id": effect.get("effect_id"), "path": labels,
"predictor": labels[0], "mediator": " -> ".join(labels[1:-1]), "outcome": labels[-1],
"numbered_hypothesis": bool(effect.get("numbered_hypothesis", False)),
"relation": "estimated_indirect_association",
})
ledger = {"version": version, "items": items, "indirect_analyses": indirect_rows}
# Exact compatibility key for the historical single-mediator profile.
if len(indirect_rows) == 1 and len(indirect_rows[0].get("path") or []) == 3:
p = indirect_rows[0]
ledger["indirect_analysis"] = {
"predictor": p["path"][0], "mediator": p["path"][1], "outcome": p["path"][2],
"numbered_hypothesis": p["numbered_hypothesis"], "relation": p["relation"],
}
return ledger
def build_quantitative_front_matter_contract(contract: dict[str, Any], *, version: str) -> dict[str, Any]:
quant = dict((contract or {}).get("quantitative") or {})
items = [dict(x) for x in (quant.get("constructs") or []) if isinstance(x, dict)]
# Preserve legacy role map where a role is singular, while adding an extensible item list.
role_map: dict[str, str] = {}
for role in ("predictor", "mediator", "moderator", "outcome", "control", "covariate"):
values = [x.get("label") for x in items if role in (x.get("roles") or []) and x.get("label")]
if len(values) == 1:
role_map[role] = values[0]
construct_roles = [r for r in ("predictor","mediator","moderator","outcome","control","covariate") if r in role_map]
return {
"version": version,
"policy": "adaptive_study_design_frozen_semantic_authority",
"source_topic": str((contract or {}).get("source_topic") or ""),
"source_keywords": list((contract or {}).get("source_keywords") or []),
# S1-R1: compatibility front-matter metadata must carry the complete
# R5/R6 user Research Intent Authority. Without it a degraded final
# reconciliation can silently synthesize a different title/keyword set.
"user_research_intent": dict((contract or {}).get("user_research_intent") or {}),
"construct_roles": construct_roles,
"constructs": role_map,
"construct_items": items,
"relations": list(quant.get("relations") or []),
"unsupported_seed_concepts": list((contract or {}).get("unsupported_seed_concepts") or []),
"study_design_contract_sha256": str((contract or {}).get("contract_sha256") or ""),
}
def build_qualitative_contract(topic: str, keywords: Any, metadata: dict[str, Any], *, policy_version: str = "adaptive-qualitative-v1") -> dict[str, Any]:
meta = dict(metadata or {})
facts = dict(meta.get("study_facts") or {})
user_intent = build_user_research_intent(topic, keywords, mode="qualitative")
sources = []
for index, raw in enumerate(facts.get("data_sources") or [], start=1):
label = _clean(raw, 120)
folded = label.casefold()
family = (
"interview" if "interview" in folded else
"focus_group" if "focus" in folded and "group" in folded else
"observation" if "observ" in folded else
"log" if "log" in folded else
"document" if any(x in folded for x in ("document","archiv","record")) else
"digital_trace" if any(x in folded for x in ("digital trace","clickstream","platform trace")) else
"field_note" if "field note" in folded else
"artifact" if "artifact" in folded else "other"
)
sources.append({"source_id":f"D{index:03d}","label":label,"family":family,"semantic_descriptor":label if family=="other" else ""})
actors = []
seen = set()
for row in facts.get("data_source_roles") or []:
if not isinstance(row, dict): continue
label = _clean(row.get("actor_role"), 100)
if label and label.casefold() not in seen:
actors.append({"actor_id":f"A{len(actors)+1:03d}","label":label}); seen.add(label.casefold())
themes = []
for item in meta.get("themes") or []:
if not isinstance(item, dict): continue
themes.append({
"theme_id": _clean(item.get("theme_id"), 40), "title": _clean(item.get("title"), 160),
"stages": [_clean(x,80) for x in (item.get("stages") or []) if _clean(x,80)],
"has_deviant_evidence": bool(item.get("has_deviant_evidence")),
})
design = _clean(facts.get("analytic_design") or facts.get("analysis_method"), 120)
# For system-generated qualitative evidence, explicit user method intent owns
# the design label. Verified user-provided material remains empirical truth
# and is never rewritten to satisfy a title/keyword request.
if str(meta.get("source_type") or "").strip().casefold() != "user_provided":
design = _clean(user_intent.get("qualitative_design") or design, 120)
temporal = _clean(facts.get("temporal_structure"), 160)
case_structure = _clean(facts.get("case_structure"), 160)
contract = {
"version": STUDY_DESIGN_CONTRACT_VERSION,
"mode": "qualitative",
"source_type": _clean(meta.get("source_type"),40),
"source_topic": str(user_intent.get("source_topic") or re.sub(r"\s+", " ", str(topic or "")).strip()),
"source_keywords": list(user_intent.get("source_keywords") or _verbatim_keywords(keywords)),
"user_research_intent": user_intent,
"keyword_policy": dict(user_intent.get("keyword_policy") or resolve_keyword_policy()),
"research_problem": "",
"research_questions": [],
"design_family": design or "Iterative thematic/process analysis",
"evidence_strength": {"causal_identification":False,"allowed_inference":["theme","process_pattern","interpretive_mechanism"],"forbidden_inference":["causal_effect"]},
"context": {"research_setting": _clean(facts.get("research_setting"),240)},
"quantitative": None,
"qualitative": {
"phenomenon": _clean(topic,300),
"organizational_puzzle": "",
"research_questions": [],
"design_tradition": design or "Iterative thematic/process analysis",
"units_of_analysis": [{"unit_id":"U001","label":case_structure or "study participants/organizational setting","level":"other"}],
"actor_groups": actors,
"data_sources": sources,
"sampling": {"participant_count": facts.get("participant_count"), "interview_count": facts.get("interview_count")},
"temporal_scope": {"description": temporal or "Not specified"},
"analysis_strategy": [design] if design else [],
"themes": themes,
"process_model": [],
"trustworthiness_procedures": list(facts.get("trustworthiness_procedures") or []),
"ethics_procedures": list(facts.get("ethics_procedures") or []),
"reflexivity_procedures": list(facts.get("reflexivity_procedures") or []),
},
"unsupported_seed_concepts": [],
"compiler_decisions": [],
"policy_version": policy_version,
}
return _finalize(contract)
def _study_owned_sentence(sentence: str) -> bool:
text = str(sentence or "")
if re.search(r"\bCITE-\d+\b", text, flags=re.I): return False
if re.search(r"\((?=[^()\n]*(?:19|20)\d{2})[^()\n]+\)", text): return False
if re.search(r"(?i)\b(?:prior|previous|existing|earlier)\s+(?:research|stud(?:y|ies)|literature)\b", text): return False
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))
def inspect_study_design_scope(text: str, contract: dict[str, Any], *, section_name: str = "") -> list[dict[str, Any]]:
"""High-confidence whole-manuscript scope guard for both modes."""
value = str(text or "")
rows: list[dict[str, Any]] = []
mode = str((contract or {}).get("mode") or "")
sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+|\n+", value) if s.strip()]
if mode == "quantitative":
quant = dict((contract or {}).get("quantitative") or {})
constructs = [x for x in (quant.get("constructs") or []) if isinstance(x, dict)]
allowed_labels = {str(x.get("label") or "").casefold() for x in constructs if x.get("label")}
has_moderation = any(r.get("type") == "moderation" for r in (quant.get("relations") or []) if isinstance(r, dict))
has_indirect = bool(quant.get("indirect_effects"))
unsupported = [str(x) for x in (contract or {}).get("unsupported_seed_concepts") or [] if str(x).strip()]
for sentence in sentences:
if not _study_owned_sentence(sentence): continue
folded = sentence.casefold()
if re.search(r"\bmoderat(?:e|es|ed|ing|or|ion)\b|\binteraction\s+(?:effect|term)\b", folded) and not has_moderation:
rows.append({"type":"unsupported_moderation_claim","preview":sentence[:260],"reason":"The frozen study-design graph contains no moderation/interaction analysis target."})
if re.search(r"\bmediat(?:e|es|ed|ing|or|ion)\b|\bindirect\s+(?:effect|association)\b", folded) and not has_indirect:
rows.append({"type":"unsupported_mediation_claim","preview":sentence[:260],"reason":"The frozen study-design graph contains no indirect-effect analysis target."})
for concept in unsupported:
if concept.casefold() in folded and re.search(r"(?i)\b(?:examine|investigate|test|model|measure|assess|estimate)\b", sentence):
rows.append({"type":"unsupported_focal_construct","construct":concept,"preview":sentence[:260],"reason":"The concept is background-only under the frozen study-design contract."})
elif mode == "qualitative":
qual = dict((contract or {}).get("qualitative") or {})
design = str(qual.get("design_tradition") or "").casefold()
temporal = str(dict(qual.get("temporal_scope") or {}).get("description") or "").casefold()
families = {str(x.get("family") or "") for x in (qual.get("data_sources") or []) if isinstance(x, dict)}
unit_label = " ".join(str(x.get("label") or "") for x in (qual.get("units_of_analysis") or []) if isinstance(x, dict)).casefold()
for sentence in sentences:
if not _study_owned_sentence(sentence): continue
folded = sentence.casefold()
if "longitudinal" in folded and "longitudinal" not in temporal and "longitudinal" not in design:
rows.append({"type":"unsupported_temporal_scope","preview":sentence[:260],"reason":"The frozen qualitative design does not establish longitudinal evidence."})
if "ethnograph" in folded and "ethnograph" not in design:
rows.append({"type":"unsupported_design_tradition","preview":sentence[:260],"reason":"Ethnography is not the frozen qualitative design tradition."})
if "grounded theory" in folded and "grounded theory" not in design:
rows.append({"type":"unsupported_design_tradition","preview":sentence[:260],"reason":"Grounded theory is not the frozen qualitative design tradition."})
if "triangulat" in folded and len({x for x in families if x}) < 2:
rows.append({"type":"unsupported_data_source_claim","preview":sentence[:260],"reason":"Triangulation requires at least two frozen evidence-source families."})
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")):
rows.append({"type":"unsupported_unit_of_analysis","preview":sentence[:260],"reason":"The frozen qualitative contract does not establish a multi-case comparison."})
return rows[:8]