from __future__ import annotations
"""Summary and attention-flag utilities for cross-pattern linkage.
These functions consume CandidateCrossPatternEvidence objects (or their dict
representations) and produce text or structured summaries for injection into
rca_card and run_manifest.
"""
from typing import Any, Dict, List, Optional
from .models import CandidateCrossPatternEvidence
# RCA-card wording from §4.7 linkage_outcome table
[docs]
_OUTCOME_WORDING: Dict[str, str] = {
"linked": "", # normal cross-pattern summary; handled by format function
"no_data": (
"No historical signal or document data available for cross-pattern assessment."
),
"no_match": (
"No historically similar signal episodes were found for this event."
),
"below_threshold": (
"Historical episodes and documents were found but could not be reliably linked."
),
}
[docs]
def build_manifest_cross_pattern_summary(
candidate_evidences: List[CandidateCrossPatternEvidence],
total_episodes: int,
total_docs: int,
total_links: int,
links_above_threshold: int,
) -> Dict[str, Any]:
"""Return structured summary for run_manifest.artifacts.
Includes per-candidate linkage outcome and support posture breakdowns.
"""
outcome_dist: Dict[str, int] = {
"linked": 0,
"no_data": 0,
"no_match": 0,
"below_threshold": 0,
}
posture_dist: Dict[str, int] = {}
precedence_dist: Dict[int, int] = {}
temporal_skipped_count = 0
for ev in candidate_evidences:
outcome_dist[ev.linkage_outcome] = outcome_dist.get(ev.linkage_outcome, 0) + 1
posture_dist[ev.support_posture] = posture_dist.get(ev.support_posture, 0) + 1
for lnk in ev.evidence_paths:
lvl = lnk.linkage_precedence_level
precedence_dist[lvl] = precedence_dist.get(lvl, 0) + 1
if lnk.temporal_link_skipped:
temporal_skipped_count += 1
candidate_summaries = [
{
"candidate_id": ev.candidate_id,
"fm_id": ev.fm_id,
"linkage_outcome": ev.linkage_outcome,
"support_posture": ev.support_posture,
"reinforcement_strength": ev.reinforcement_strength,
"best_link_score": ev.best_link_score,
"linked_episode_count": len(ev.linked_episode_ids),
"linked_doc_count": len(ev.linked_doc_ids),
"evidence_path_count": len(ev.evidence_paths),
}
for ev in candidate_evidences
]
return {
"present": True,
"total_episodes_retrieved": total_episodes,
"total_doc_extractions": total_docs,
"total_links_built": total_links,
"links_above_threshold": links_above_threshold,
"linkage_outcome_distribution": outcome_dist,
"support_posture_distribution": posture_dist,
"linkage_precedence_distribution": {
str(k): v for k, v in sorted(precedence_dist.items())
},
"temporal_link_skipped_count": temporal_skipped_count,
"candidate_summaries": candidate_summaries,
}
[docs]
def get_cross_pattern_attention_flags(
candidate_evidences: List[CandidateCrossPatternEvidence],
candidates: List[Dict[str, Any]], # ranked candidates (index 0 = top)
top_n_candidates: int = 3,
) -> List[str]:
"""Return attention flag strings for injection into analyst_attention_flags.
Flags raised:
- "conflicting" support posture on any of top_n_candidates
- "multiple_consistent" reinforcement (positive signal) on top candidates
- "no_data" outcome (data coverage gap) for any candidate
- "stale" index (detected via provenance on any link)
"""
flags: List[str] = []
# Build lookup by candidate_id
evidence_by_id: Dict[str, CandidateCrossPatternEvidence] = {
ev.candidate_id: ev for ev in candidate_evidences
}
# Determine top-N candidate IDs (by order in candidates list)
top_ids = [
str(c.get("candidate_id") or "")
for c in candidates[:top_n_candidates]
if c.get("candidate_id")
]
for cand_id in top_ids:
ev = evidence_by_id.get(cand_id)
if ev is None:
continue
if ev.support_posture == "conflicting":
flags.append(
f"Cross-pattern evidence conflicts with candidate {cand_id} "
f"(fm_id={ev.fm_id}); analyst review required."
)
if (
ev.support_posture == "reinforcing"
and ev.reinforcement_strength == "multiple_consistent"
):
flags.append(
f"Multiple consistent cross-pattern links reinforce candidate {cand_id} "
f"(fm_id={ev.fm_id})."
)
# Check all candidates for no_data
no_data_cands = [ev for ev in candidate_evidences if ev.linkage_outcome == "no_data"]
if no_data_cands:
fm_ids_str = ", ".join(sorted(set(ev.fm_id for ev in no_data_cands))[:5])
flags.append(
"No historical signal or document data available for cross-pattern "
f"assessment on {len(no_data_cands)} candidate(s) ({fm_ids_str})."
)
# Check for stale index via provenance on any link
stale_detected = False
for ev in candidate_evidences:
for lnk in ev.evidence_paths:
if (lnk.provenance or {}).get("ep_index_status") == "stale":
stale_detected = True
break
if stale_detected:
break
if stale_detected:
flags.append(
"Signal episode index is stale; cross-pattern results may not reflect "
"recent plant history."
)
return flags