#!/usr/bin/env python3
"""Assessment Assurance Audit -- Components 3, 4, and 5.

Consumes build_inventory() from assurance_inventory.py and produces a
per-course scorecard with three scored dimensions plus a learning-science
pointer, a needs_ai_review item list, and a stable JSON schema.

Public API
----------
score_course(config, *, inventory=None, ai_securing=None) -> dict
    Returns a scorecard dict with stable golden-tested schema (see SCORECARD_SCHEMA
    below for the canonical field list).

    If `inventory` is provided (pre-built dict from build_inventory), it is used
    directly and no Canvas API calls are made.  Otherwise build_inventory(config)
    is called internally.

    `ai_securing` is the AI-judged securing classification produced by the
    skill's Step 2 pass: {assessment_id: {"secured": bool,
    "kind": "identity"|"process"|"unsecured", "evidence": str}}. When omitted,
    lane classification falls back to the deterministic-only floor
    (proctoring_signal only; see _classify_lane).

Scorecard schema (all keys always present):
    {
        "course":                   {"id": str, "name": str, "tier": str},
        "tier":                     "Assured" | "Partially Assured" | "Unassured"
                                     (coverage-derived only; "Counterfeit" is
                                     deprecated-not-assigned -- see AssuranceTier).
                                     DEPRECATED, NOT FEATURED: kept for back-compat
                                     and internal batch tiebreak only; renderers lead
                                     with security_ledger instead (plan: assurance-
                                     binary-labels).
        "assurance_coverage_pct":   float,  # DEPRECATED, NOT FEATURED -- see tier note above.
        "security_ledger":          list[{
                                       "id": int, "title": str, "grade_weight_pct": float,
                                       "graded": bool,
                                       "security": "secured" | "unsecured",
                                       "security_basis": "identity" | "authorship" | "none",
                                       "security_evidence": str, "needs_ai_review": bool,
                                       "design": "ai_forward" | "ai_resistant" | "n/a",
                                       "design_basis": str,
                                     }],  # the featured per-assessment labels; one entry
                                          # per assessment; see _compute_security_ledger.
        "ai_policy_in_syllabus":    bool,   # (#219) replaces aias_transparency_pct:
                                             # was a policy stated in the syllabus?
        "ai_declared_assessments":  {"count": int, "total": int},  # (#219) how many
                                             # assignment/quiz descriptions carried a
                                             # task-level AI declaration, of how many
        "counterfeit_claim":        bool,   # (#219) mismatch/counterfeit-claim flag,
                                             # layered on the coverage-derived tier
        "has_graded_assessments":   bool,   # (#219) False for a truly empty course
                                             # (zero graded assessments); such courses
                                             # are excluded from certifying_on_nothing
        "lane_balance":             {"lane1": int, "lane2": int, "mismatch": int, "unclassified": int},
        "dimensions": {
            "A": {...},   # Authenticity (AI top-K, deterministic tiering)
            "B": {...},   # Learning Science (pointer/deltas only, NOT scored)
            "C": {...},   # Cognitive Demand (deterministic + counterfeit)
            "D": {...},   # Assurance & AI-Integration (headline, deterministic + bounded AI)
            "E": {...},   # Equity and Feasibility (deterministic, per-course only, not a ranking axis)
            "F": {...},   # AI-Forward axis (deterministic, orthogonal to tier, not a ranking axis)
        },
        "ai_forward_rating":         "Needs redesign" | "Use with safeguards" | "AI-resistant" | "AI-forward",
        "ai_forward_dimensions":     {D1..D6: {"anchor": int, "label": str, "basis": str}},
        "bloom_calibration_findings": list[dict],  # deterministic per high-weight item
        "flags": {
            "counterfeit_assurance": bool,
            "transparency_gap":      bool,
            "no_anchor":             bool,
            "over_surveillance":     bool,
        },
        "durable_skills": list[str],
        "needs_ai_review": list[dict],
        "assumptions": {
            "K":                        int,
            "top_k_weight_cap_pct":     float,
            "model_capability":         str,
            "modality":                 str,
            "prompt_version":           str,
            "deterministic_vs_ai":      str,
            "inventory_assumptions":    list[str],
        },
    }

Design contracts
----------------
- The ARITHMETIC over lane classification (tier, assurance_coverage_pct,
  lane_balance, flags, dimensions.C, dimensions.D.lane_summary,
  dimensions.D.coverage_band) is deterministic. No needs_ai_review value feeds
  it. Its lane-classification INPUT is deterministic-only (proctoring_signal)
  unless the caller supplies `ai_securing`, in which case the AI-judged
  securing classification (identity or process, cited evidence) feeds the
  same deterministic arithmetic. See docs/plan-assurance-ai-led.md.
- AI annotation (dimensions.A, needs_ai_review) is bounded, labeled, and
  packaged for the caller to judge -- this module never calls an LLM.
- top-K cap covers ~80% of grade weight; K and the cap are recorded in assumptions.
- Each needs_ai_review item carries a stable prompt_version and content_hash
  (sha256 of assessment text) for judgment caching.
- No import of criterion_evaluator.  No zip/IMSCC code.
- No em or en dashes in string literals (use colons/semicolons/periods).
"""

from __future__ import annotations

import hashlib
import logging
import os
import re
import sys
from typing import Any

_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPTS_DIR not in sys.path:
    sys.path.insert(0, _SCRIPTS_DIR)

import canvas_api
from assurance_inventory import build_inventory
from blooms import classify_blooms, BLOOMS_LEVELS

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

# Prompt version string -- bump when judgment prompts change so cached judgments
# are invalidated.
PROMPT_VERSION = "assurance-audit-v1.0"

# Default model capability assumption (never calls LLM here; recorded in output).
DEFAULT_MODEL_CAPABILITY = "conservative"

# Default modality assumption for batch runs (no user interaction).
DEFAULT_MODALITY = "async_online"

# Top-K weight coverage target (select assessments covering this fraction of weight)
TOP_K_WEIGHT_TARGET = 0.80  # 80%

# Assured tier threshold: must have >= this % of weight on secured Lane 1 anchors.
ASSURED_COVERAGE_THRESHOLD = 40.0  # percent

# Mismatch: high-weight items lacking security property trigger a Mismatch lane.
# Items with grade_weight_pct >= this value and no security property are Mismatch.
HIGH_WEIGHT_THRESHOLD = 20.0  # percent

# Over-surveillance flag: triggers when Lane 1 (secured) weight exceeds this.
OVER_SURVEILLANCE_THRESHOLD = 80.0  # percent

# Equity/Feasibility dimension (E) thresholds.
# SURVEILLANCE_HIGH_BURDEN_PCT is proctoring-only; it is intentionally distinct
# from OVER_SURVEILLANCE_THRESHOLD, which is a lane1-weight flag (proctored items
# AND AI-securing-confirmed items both count toward lane1, not just proctoring).
SURVEILLANCE_HIGH_BURDEN_PCT = 50.0  # percent of grade weight that is proctored
SYNC_ANCHOR_COUNT = 2               # minimum oral/live Lane 1 items to flag synchronous burden
CONCENTRATION_PCT = 70.0            # percent of total weight in high-stakes items to flag concentration

# Keywords that suggest oral/process-trail submission (supplement proctoring_signal)
_ORAL_KEYWORDS = frozenset({
    "oral", "presentation", "defense", "viva", "deposition", "hearing",
    "moot", "mock trial", "negotiation", "interview", "performance",
})

_PROCESS_TRAIL_KEYWORDS = frozenset({
    "draft", "revision", "portfolio", "reflection", "journal", "lab report",
    "lab notebook", "annotation", "process log", "peer review", "eportfolio",
    "e-portfolio", "work sample", "clinical", "fieldwork", "practicum",
})

# Additional authorship-nominator keywords (plan: assurance-binary-labels %5a).
# Deliberately NOT merged into _PROCESS_TRAIL_KEYWORDS: that set also feeds
# _classify_lane, _aiforward_item_anchor_d5, and _compute_bloom_calibration_findings,
# and several of these terms (e.g. "local") are broader than is safe to add to
# those already-tested paths. _is_authorship_nominated unions this set with
# _PROCESS_TRAIL_KEYWORDS and _ORAL_KEYWORDS instead, scoped to the new
# security_ledger nominator only. Nomination never secures on its own (cite-or-
# fail); it only sets needs_ai_review when no ai_securing entry exists.
_AUTHORSHIP_NOMINATOR_EXTRA_KEYWORDS = frozenset({
    "decision log", "ai-use appendix", "ai use appendix", "ai appendix",
    "milestone", "check-in", "version history", "commit history", "changelog",
    "process memo", "personal reflection", "local", "your own",
    "your workplace", "your community",
})

_TAKE_HOME_ESSAY_KEYWORDS = frozenset({
    "essay", "paper", "memo", "brief", "argument", "analysis", "case study",
    "report", "synthesis", "response", "reflection", "take-home",
    "take home",
})

# Objective question types that make high-level Bloom claims counterfeit
# (auto-graded, cannot measure Analyze+).
_OBJECTIVE_QUESTION_TYPES = frozenset({
    "multiple_choice", "multiple_answers", "true_false", "matching",
    "fill_in_multiple_blanks",
})

# Bloom levels at which objective items are counterfeit if claiming them.
COUNTERFEIT_OBJECTIVE_BLOOM_THRESHOLD = 4  # Analyze=4, Evaluate=5, Create=6

# AIAS presence: keywords that signal an AI-use policy exists.
# "prompt" and "chatgpt" are intentionally removed from standalone triggers:
# - "prompt" is far too common in assignment descriptions (submission prompts, writing
#   prompts) and generates false positives on nearly every course.
# - "chatgpt" alone without adjacent policy language is not a reliable policy signal.
# Both are retained in _AI_REFERENCE_KEYWORDS (for the AI-Forward axis content scan)
# where they appear alongside other context that reduces false-positive risk.
_AIAS_POLICY_KEYWORDS = frozenset({
    "ai policy", "ai use policy", "artificial intelligence policy",
    "generative ai", "gen ai", "ai-generated", "ai generated",
    "use of ai", "ai assistance", "llm", "large language model",
    "ai tools", "ai writing", "ai disclosure", "aias",
    "ai academic integrity", "academic integrity statement",
})

# Submission types considered take-home / uncontrolled for lane classification.
_UNCONTROLLED_SUBMISSION_TYPES = frozenset({
    "online_upload", "online_text_entry", "online_url",
})


# ---------------------------------------------------------------------------
# AI-Forward axis (separate from the assurance tier)
#
# Mined from the faculty AI-Forward / AI-Resistant rubric tool. The assurance
# tier answers "can the course certify learning" (resistance). The AI-Forward
# rating answers "does the design teach and assess ethical, judgment-based AI
# use" (forwardness). They are orthogonal: a course can be Assured but
# Needs-redesign on AI-forward, or AI-forward but Unassured.
#
# Anchor scale (the rubric's four categorical anchors):
#   0 = Needs redesign, 1 = Use with safeguards, 2 = AI-resistant, 3 = AI-forward
#
# The rating is DETERMINISTIC, like the tier. AIAS *level* (1-5) and the
# judgment/authorship refinements are AI-judged and never alter the rating;
# they enrich the narrative only (needs_ai_review).
# ---------------------------------------------------------------------------

AI_FORWARD_ANCHORS = ["Needs redesign", "Use with safeguards", "AI-resistant", "AI-forward"]

# Verbs that signal the task requires the student's own reasoning/judgment
# (rubric dimension D4: human judgment and AI literacy).
_JUDGMENT_VERB_KEYWORDS = frozenset({
    "justify", "defend", "critique", "evaluate", "compare", "audit",
    "appraise", "argue", "recommend", "prioritize", "rationale", "rationalize",
    "weigh", "assess", "judge", "decision memo", "tradeoff", "trade-off",
})

# Weak reasoning signals (D4 anchor level 1).
_WEAK_REASONING_KEYWORDS = frozenset({
    "analysis", "analyze", "response", "discussion", "interpret", "explain",
})

# Reference to AI in the task itself (combine with judgment verb for D4 anchor 3,
# or with process evidence for D5 anchor 3).
_AI_REFERENCE_KEYWORDS = frozenset({
    "ai appendix", "ai-use", "ai use", "prompt log", "prompt", "chatgpt",
    "generative ai", "gen ai", "llm", "ai output", "ai-generated", "ai generated",
    "ai-assisted", "ai assisted", "disclose ai", "ai disclosure",
})

# Documentation-of-AI-use signals (D5 anchor 3: prompts, revisions, verification).
_AI_DOC_KEYWORDS = frozenset({
    "ai appendix", "prompt log", "prompt history", "verification log",
    "ai-use appendix", "ai use appendix", "revision log",
})

# High-stakes weight floor for the auto-rule (rubric: "High-stakes tasks with no
# assurance anchor are automatically flagged"). Reuses HIGH_WEIGHT_THRESHOLD.


# ---------------------------------------------------------------------------
# Tier enum (the canonical batch sort key)
# ---------------------------------------------------------------------------

class AssuranceTier:
    """Named tier constants for the cross-course comparable sort key.

    (#219) COUNTERFEIT is DEPRECATED-NOT-ASSIGNED: _compute_assurance_coverage
    no longer assigns it. Tier is now derived purely from secured coverage
    (ASSURED_COVERAGE_THRESHOLD + anchor_count). A mismatch/counterfeit-claim
    condition used to force this tier, which meant a course with 60% secured
    coverage and one unrelated mismatch item ranked worse than a clean course
    with 5% coverage -- that is backwards for a cross-course ranking axis. The
    same condition is now surfaced as the `counterfeit_claim` boolean scorecard
    flag (and `flags["counterfeit_assurance"]`), layered on top of the
    coverage-derived tier rather than overriding it. The constant is retained
    (not removed) so historical scorecard JSON files written before this
    change -- which do contain tier == "Counterfeit" -- still sort and render
    correctly in assurance_batch.py.
    """
    ASSURED = "Assured"
    PARTIALLY_ASSURED = "Partially Assured"
    UNASSURED = "Unassured"
    COUNTERFEIT = "Counterfeit"  # deprecated-not-assigned; see class docstring

    @staticmethod
    def all_values():
        return [
            AssuranceTier.ASSURED,
            AssuranceTier.PARTIALLY_ASSURED,
            AssuranceTier.UNASSURED,
            AssuranceTier.COUNTERFEIT,
        ]


# ---------------------------------------------------------------------------
# Shared word-boundary keyword helper (criteria A + C)
# ---------------------------------------------------------------------------

def _keyword_hit(text: str, keywords) -> bool:
    """Return True when any keyword in `keywords` matches as a whole word in `text`.

    Uses re.search with word boundaries so that "oral" does not match inside
    "behavioral", "presentation" does not match inside "physical presentation",
    and "llm" does not match inside "llms" (though "llms" also needs its own
    entry if desired).

    Multi-word keywords (e.g. "peer review") are escaped and bounded at the
    first and last word token, so they still match correctly.

    `text` is expected to be lowercased by the caller.
    `keywords` is any iterable of lowercase keyword strings.
    """
    for kw in keywords:
        pattern = r"\b" + re.escape(kw) + r"\b"
        if re.search(pattern, text):
            return True
    return False


# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------

def _is_quiz(record: dict) -> bool:
    """Return True when the record represents an auto-graded objective quiz.

    Matches the predicate previously inlined in _compute_cognitive_demand,
    _package_ai_items, and _classify_lane.
    """
    return (
        record.get("canvas_type") == "quiz"
        or record.get("engine") in ("classic_quiz", "new_quizzes", "new_quizzes_opaque")
    )


def _rec_bloom(record: dict) -> tuple[str, str, str]:
    """Return (verb, level, source) for a record's Bloom level.

    Prefers the inventory-attached bloom_level (which already encodes the
    fill-Unknown rule: title verb first, module MLO fallback) and its
    bloom_source. Falls back to classify_blooms(title) for records that predate
    the grounding (raw test records), so existing behavior is preserved.
    """
    level = record.get("bloom_level")
    if level and level in BLOOMS_LEVELS:
        # Recover the title verb for display (empty when the level came from MLOs,
        # since an MLO-grounded title carries no recognized Bloom verb).
        verb, _ = classify_blooms(record.get("title") or "")
        return verb, level, record.get("bloom_source", "title")
    verb, level = classify_blooms(record.get("title") or "")
    return verb, level, ("title" if level in BLOOMS_LEVELS else "unknown")


# ---------------------------------------------------------------------------
# Lane classification (deterministic)
# ---------------------------------------------------------------------------

def _classify_lane(record: dict, ai_securing_entry: dict | None = None) -> str:
    """Classify one assessment into a lane.

    Returns one of: "lane1", "lane2", "lane1_candidate", "mismatch", "unclassified".

    Secured = a real basis to trust the student did the work. Two kinds:
      - Identity/condition control -- proctoring_signal is True (API fact:
        access_code / ip_filter / proctoring LTI). Deterministic.
      - Authorship evidence -- drafts, decision logs, revision history,
        reflection, a graded AI-use appendix, a defensible process account.
        NOT an API flag; AI reads the assignment/rubric/syllabus and judges
        it, citing evidence. Carried in `ai_securing_entry` as
        {"secured": bool, "kind": "identity"|"process"|"unsecured", "evidence": str}.

    Lane 1 (secured) iff:
      - proctoring_signal is True, OR
      - ai_securing_entry is provided AND ai_securing_entry.get("secured") is True.

    `peer_reviews` is recorded as a fact on the record (the AI pass may weigh
    it as circumstantial evidence) but never auto-secures on its own -- peer
    review does not verify authorship.

    Keyword-nominated Lane 1 candidates:
      - Oral/process-trail keywords in title or content_excerpt nominate the item
        as a lane1_candidate. When no `ai_securing_entry` is supplied for this
        item, the nomination does NOT promote it to "lane1" -- it stays "lane2"
        and score_course emits a lane_review needs_ai_review advisory (the
        deterministic-only fallback). When an `ai_securing_entry` IS supplied,
        that AI classification is authoritative and no advisory is needed.

    Lane 2 (take-home, uncontrolled):
      - take-home essay / quiz with no security property

    Mismatch:
      - high grade_weight_pct AND no security property (and not a keyword candidate)
      - (flagged as a structural integrity problem, not a criminal accusation)

    Unclassified:
      - everything else

    Deterministic-only fallback (ai_securing_entry is None): lane1 iff
    proctoring_signal is True only. This is the conservative floor used when
    no AI securing pass has run. Keyword hits still produce "lane2" + a
    lane_review advisory item; they never auto-promote to "lane1" in this path.

    Criterion D: proctoring_signal="unknown" is treated the same as False for
    lane classification (cannot be counted as a secured anchor). A lane_review
    needs_ai_review item is emitted by score_course for ambiguous high-weight
    items so Claude can confirm the lane from the description body.
    """
    title_lower = (record.get("title") or "").lower()
    # Also scan description_excerpt for explicit auth mechanisms (criterion D).
    desc_lower = (record.get("content_excerpt") or "").lower()
    combined_lower = title_lower + " " + desc_lower
    sub_types = set(record.get("submission_types") or [])
    pct = record.get("grade_weight_pct") or 0.0
    # Tri-state: only True counts as confirmed proctored (not "unknown").
    proctored = record.get("proctoring_signal") is True
    # peer_reviews is recorded as a fact only; it never auto-secures (peer
    # review does not verify authorship).
    peer = bool(record.get("peer_reviews", False))
    ai_secured = isinstance(ai_securing_entry, dict) and ai_securing_entry.get("secured") is True

    # ---- Lane 1 detection: identity control OR AI-judged authorship evidence ----
    # proctoring_signal is a hard API fact and is authoritative: an explicit AI
    # "unsecured" judgment cannot downgrade a proctored/LockDown item (house rule:
    # a LockDown exam is a secured anchor).
    if proctored or ai_secured:
        return "lane1"

    # ---- Keyword scan (word-boundary): advisory only, not auto-promoting ----
    # Oral and process-trail keywords in the title or description nominate the
    # item as a lane1_candidate. The item stays Lane 2 for deterministic coverage;
    # score_course emits a lane_review item so Claude can confirm.
    has_oral_kw = _keyword_hit(combined_lower, _ORAL_KEYWORDS)
    has_process_kw = _keyword_hit(combined_lower, _PROCESS_TRAIL_KEYWORDS)
    is_keyword_candidate = has_oral_kw or has_process_kw

    # ---- Security property check (used for Lane 2 and Mismatch) ----
    # Keywords are NOT a confirmed security property for deterministic coverage.
    # peer is a recorded fact only, never a confirmed security property.
    has_confirmed_security = proctored

    # ---- Mismatch: high-weight with no confirmed security property ----
    if pct >= HIGH_WEIGHT_THRESHOLD and not has_confirmed_security and not is_keyword_candidate:
        return "mismatch"

    # ---- Lane 2: take-home essay, quiz-written, online upload with essay keywords ----
    # Keyword-nominated candidates also fall here (counted Lane 2 until AI confirms).
    has_essay = _keyword_hit(combined_lower, _TAKE_HOME_ESSAY_KEYWORDS)
    is_online_uncontrolled = bool(sub_types & _UNCONTROLLED_SUBMISSION_TYPES)
    canvas_type = record.get("canvas_type", "")
    engine = record.get("engine", "")
    is_quiz_type = canvas_type == "quiz" or engine in ("classic_quiz", "new_quizzes", "new_quizzes_opaque")

    if is_keyword_candidate:
        # Keyword nominees are Lane 2 deterministically; score_course emits lane_review.
        return "lane2"

    if is_online_uncontrolled or (is_quiz_type and not proctored):
        return "lane2"
    if has_essay:
        return "lane2"

    return "unclassified"


def _is_lane1_keyword_candidate(record: dict) -> bool:
    """Return True when this record was nominated for lane1 by keyword scan only.

    Used by score_course to emit lane_review items for ALL keyword-nominated
    records regardless of grade weight (criterion A: remove HIGH_WEIGHT_THRESHOLD
    gate for keyword nominees).
    """
    title_lower = (record.get("title") or "").lower()
    desc_lower = (record.get("content_excerpt") or "").lower()
    combined_lower = title_lower + " " + desc_lower
    # Must NOT have confirmed structural security (already lane1 via proctoring).
    proctored = record.get("proctoring_signal") is True
    if proctored:
        return False
    return _keyword_hit(combined_lower, _ORAL_KEYWORDS) or _keyword_hit(combined_lower, _PROCESS_TRAIL_KEYWORDS)


def _is_secured_lane1(record: dict, lane: str) -> bool:
    """Return True when this is a confirmed secured Lane 1 anchor.

    "lane1" is assigned by _classify_lane when proctoring_signal is True
    (structured Canvas metadata API fact) OR an ai_securing_entry marks the
    item secured (AI-judged authorship evidence, cited). peer_reviews never
    auto-secures. Keyword-nominated items with no ai_securing_entry are
    classified "lane2" until AI confirms, so they never reach here as "lane1"
    in the deterministic-only fallback path.

    Criterion D guard: proctoring_signal="unknown" is treated as False in
    _classify_lane (the `proctored = record.get("proctoring_signal") is True`
    check ensures only the boolean True passes). Any record with lane=="lane1"
    therefore has at least one confirmed structural security property.
    """
    return lane == "lane1"


def _is_authorship_nominated(record: dict) -> bool:
    """Return True when the deterministic nominator flags this item as a
    plausible authorship-evidence anchor (plan: assurance-binary-labels %5a).

    Nomination alone never secures (cite-or-fail; see _compute_security_ledger).
    It sets security_ledger[i]["needs_ai_review"] when no ai_securing entry
    exists for the item -- the honest signal that "an AI read would resolve
    this," not a promotion to secured.

    Any ONE of the following nominates:
      - an oral or process-trail keyword hit (_ORAL_KEYWORDS / _PROCESS_TRAIL_KEYWORDS)
      - an expanded authorship-nominator keyword hit (_AUTHORSHIP_NOMINATOR_EXTRA_KEYWORDS:
        decision log, AI-use appendix, milestone, check-in, version history,
        commit history, changelog, process memo, personal reflection, local,
        your own, your workplace, your community)
      - peer_reviews is True (structural fact; circumstantial support only)
      - submission_types includes "media_recording" (an oral/authorship signal)
      - rubric_excerpt, when present, references drafts/process/revision/reflection
    """
    title_lower = (record.get("title") or "").lower()
    desc_lower = (record.get("content_excerpt") or "").lower()
    combined_lower = title_lower + " " + desc_lower

    all_kw = _ORAL_KEYWORDS | _PROCESS_TRAIL_KEYWORDS | _AUTHORSHIP_NOMINATOR_EXTRA_KEYWORDS
    if _keyword_hit(combined_lower, all_kw):
        return True

    if record.get("peer_reviews") is True:
        return True

    sub_types = set(record.get("submission_types") or [])
    if "media_recording" in sub_types:
        return True

    rubric_lower = (record.get("rubric_excerpt") or "").lower()
    if rubric_lower and _keyword_hit(rubric_lower, all_kw):
        return True

    return False


# ---------------------------------------------------------------------------
# AIAS presence detection (deterministic prefilter)
# ---------------------------------------------------------------------------

def _detect_aias_presence(syllabus_text: str, descriptions: list[tuple[str, str]]) -> dict:
    """Detect AI-use policy presence across syllabus and assignment descriptions.

    Returns:
        {
            "declared_presence": bool,
            "found_in": list[str],  # "syllabus", "assignment:<title>"
            "assessed_count": int,
            "declared_count": int,
            "policy_excerpts": list[str],  # matched spans for Claude's inline read (criterion C)
            "syllabus_declared": bool,             # (#219) policy found in the syllabus specifically
            "assessment_assessed_count": int,      # (#219) assignment/quiz descriptions scanned (excludes syllabus)
            "assessment_declared_count": int,      # (#219) of those, how many carried a declaration
        }

    Level 1-5 classification is AI-judged (Component 4), not here.
    Undeclared -> transparency flag in the caller.
    _AIAS_POLICY_KEYWORDS acts as a PREFILTER to locate candidate text, not as
    the final verdict. The matched excerpt is carried in policy_excerpts so Claude
    can read the actual wording and classify AIAS 1-5 inline.

    (#219) The single combined declared_count/assessed_count ratio historically
    fed a single "aias_transparency_pct" score that conflated two very different
    signals: a syllabus-wide policy statement (one surface, huge leverage) and
    per-assignment task-level declarations (N surfaces). A policy stated once in
    the syllabus scored ~9% (1/11 surfaces) while keyword-stuffed assignment text
    with no real policy could score ~100%. syllabus_declared and the
    assessment_*_count pair let the caller report the two signals separately
    instead of blending them into one misleading ratio.
    """
    found_in: list[str] = []
    policy_excerpts: list[str] = []
    assessed_texts = []

    if syllabus_text:
        assessed_texts.append(("syllabus", syllabus_text))

    for label, text in descriptions:
        if text:
            assessed_texts.append((label, text))

    for label, text in assessed_texts:
        text_lower = text.lower()
        matched_kw = None
        for kw in _AIAS_POLICY_KEYWORDS:
            # Criterion C: use word-boundary matching to avoid short tokens
            # (e.g., "llm") matching inside longer words.
            pattern = r"\b" + re.escape(kw) + r"\b"
            if re.search(pattern, text_lower):
                matched_kw = kw
                break
        if matched_kw is not None:
            found_in.append(label)
            # Extract a snippet around the match for Claude's inline read.
            idx = text_lower.find(matched_kw)
            start = max(0, idx - 80)
            end = min(len(text), idx + len(matched_kw) + 200)
            policy_excerpts.append(text[start:end].strip())

    syllabus_declared = "syllabus" in found_in
    assessment_assessed_count = len(assessed_texts) - (1 if syllabus_text else 0)
    assessment_declared_count = len(found_in) - (1 if syllabus_declared else 0)

    return {
        "declared_presence": len(found_in) > 0,
        "found_in": found_in,
        "assessed_count": len(assessed_texts),
        "declared_count": len(found_in),
        "policy_excerpts": policy_excerpts,
        "syllabus_declared": syllabus_declared,
        "assessment_assessed_count": assessment_assessed_count,
        "assessment_declared_count": assessment_declared_count,
    }


# ---------------------------------------------------------------------------
# Cognitive demand (deterministic)
# ---------------------------------------------------------------------------

def _compute_cognitive_demand(records: list[dict]) -> dict:
    """Compute grade-weighted Bloom distribution and arc.

    Returns:
        {
            "weighted_distribution": {level_name: float},  # sums to ~100.0
            "arc": "increasing" | "flat" | "decreasing",
            "surface_above_50pct_flag": bool,
            "surface_above_70pct_flag": bool,
            "level_by_assessment": list[{id, title, level, verb, grade_weight_pct}],
            "counterfeit_objective": list[{id, title, grade_weight_pct, level, engine, reason}],
        }
    """
    levels = list(BLOOMS_LEVELS.keys())
    weighted_dist: dict[str, float] = {lvl: 0.0 for lvl in levels}
    weighted_dist["Unknown"] = 0.0

    by_assessment = []
    counterfeit_objective = []

    counterfeit_objective_mlo = []
    for rec in records:
        w = rec.get("grade_weight_pct") or 0.0
        title = rec.get("title") or ""
        verb, level, source = _rec_bloom(rec)
        by_assessment.append({
            "id": rec["id"],
            "title": title,
            "level": level,
            "verb": verb,
            "bloom_source": source,
            "grade_weight_pct": w,
        })
        if level in weighted_dist:
            weighted_dist[level] += w
        else:
            weighted_dist["Unknown"] += w

        # Counterfeit check: only for objective auto-graded items.
        is_quiz = _is_quiz(rec)

        # Criterion B: bloom_mlo_capped=True means the inventory already applied
        # the Bloom inheritance cap (original MLO was Analyze+, record is now
        # stamped Understand). Emit a counterfeit_objective_mlo item so Claude can
        # verify whether the quiz actually reaches the higher level.
        if rec.get("bloom_mlo_capped"):
            counterfeit_objective_mlo.append({
                "id": rec["id"],
                "title": title,
                "grade_weight_pct": w,
                "level": rec.get("bloom_mlo_original_level", level),
                "capped_to": level,
                "reason": (
                    "Auto-graded objective quiz inherits Bloom level from module MLO "
                    "but the MLO demands " + rec.get("bloom_mlo_original_level", "Analyze+") + " -- "
                    "capped at Understand for deterministic distribution; "
                    "AI review required to confirm whether quiz actually reaches the higher level."
                ),
            })
        elif is_quiz and level in BLOOMS_LEVELS and BLOOMS_LEVELS[level] >= COUNTERFEIT_OBJECTIVE_BLOOM_THRESHOLD:
            if source == "mlo":
                # The high Bloom claim comes from the MODULE's MLOs, not the quiz's
                # own title. A low-stakes concept-check quiz in a high-level module
                # is legitimate, not counterfeit. Defer to AI judgment instead of
                # branding it deterministically.
                counterfeit_objective_mlo.append({
                    "id": rec["id"], "title": title, "grade_weight_pct": w, "level": level,
                })
            else:
                counterfeit_objective.append({
                    "id": rec["id"],
                    "title": title,
                    "grade_weight_pct": w,
                    "level": level,
                    "engine": rec.get("engine", ""),
                    "reason": (
                        f"Auto-graded quiz claims Bloom's {level} ({verb}) by title -- "
                        "objective question types cannot measure this level deterministically."
                    ),
                })

    # Bloom arc: compare lower-half to upper-half cognitive weight
    low_levels = ["Remember", "Understand"]
    mid_levels = ["Apply"]
    high_levels = ["Analyze", "Evaluate", "Create"]
    low_w = sum(weighted_dist.get(l, 0.0) for l in low_levels)
    high_w = sum(weighted_dist.get(l, 0.0) for l in high_levels)

    if high_w > low_w + 5.0:
        arc = "increasing"
    elif low_w > high_w + 5.0:
        arc = "decreasing"
    else:
        arc = "flat"

    # Surface flags: % of weight at Unknown / missing Bloom level
    unknown_pct = weighted_dist.get("Unknown", 0.0)
    surface_50 = unknown_pct > 50.0
    surface_70 = unknown_pct > 70.0

    # Bloom-source breakdown so the report can show how much grounding came from
    # module MLOs vs assessment titles vs neither.
    bloom_source_breakdown = {"title": 0, "mlo": 0, "unknown": 0}
    for a in by_assessment:
        bloom_source_breakdown[a["bloom_source"]] = bloom_source_breakdown.get(a["bloom_source"], 0) + 1

    return {
        "weighted_distribution": {k: round(v, 4) for k, v in weighted_dist.items()},
        "arc": arc,
        "surface_above_50pct_flag": surface_50,
        "surface_above_70pct_flag": surface_70,
        "level_by_assessment": by_assessment,
        "counterfeit_objective": counterfeit_objective,
        "counterfeit_objective_mlo": counterfeit_objective_mlo,
        "bloom_source_breakdown": bloom_source_breakdown,
    }


# ---------------------------------------------------------------------------
# Assurance Coverage (deterministic)
# ---------------------------------------------------------------------------

def _normalize_securing_keys(ai_securing: dict | None) -> dict[int, dict]:
    """Coerce ai_securing keys to int, matching the int record ids.

    Records carry int ids (assurance_inventory coerces via int(a["id"])). The
    ai_securing dict is hand-built by the skill and survives JSON round-trips,
    which stringify keys. Without this, string keys silently miss every lookup
    and every item falls to the deterministic floor -- a silent tier
    misclassification, not an error. Non-int-castable keys are dropped.
    """
    out: dict[int, dict] = {}
    for k, v in (ai_securing or {}).items():
        try:
            out[int(k)] = v
        except (TypeError, ValueError):
            continue
    return out


def _compute_assurance_coverage(
    records: list[dict],
    lanes: dict[int, str],
    ai_securing: dict[int, dict] | None = None,
) -> dict:
    """Compute Assurance Coverage and assign the tier enum.

    Coverage = sum(grade_weight_pct for secured Lane 1 anchors).

    Tier bands (#219: derived from secured coverage ONLY -- a mismatch no
    longer forces this tier down; see AssuranceTier.COUNTERFEIT docstring):
        Assured           >= ASSURED_COVERAGE_THRESHOLD (40%) with >= 1 anchor
        Partially Assured < 40% but > 0% on Lane 1 anchors
        Unassured         0% on Lane 1 anchors (no secured assessment)

    Returns:
        {
            "coverage_pct": float,
            "tier": str,  # AssuranceTier constant
            "anchor_count": int,
            "anchor_items": list[{id, title, grade_weight_pct, securing_kind, securing_evidence}],
            "mismatch_items": list[{id, title, grade_weight_pct}],
            "has_mismatch": bool,  # (#219) the former Counterfeit-tier trigger;
                                   # now surfaced as a flag, not a tier override.
        }
    """
    anchors = []
    mismatch_items = []
    ai_securing = _normalize_securing_keys(ai_securing)

    for rec in records:
        rid = rec["id"]
        lane = lanes.get(rid, "unclassified")
        w = rec.get("grade_weight_pct") or 0.0

        if _is_secured_lane1(rec, lane):
            entry = ai_securing.get(rid)
            anchor = {"id": rid, "title": rec.get("title", ""), "grade_weight_pct": w}
            if entry:
                anchor["securing_kind"] = entry.get("kind", "")
                anchor["securing_evidence"] = entry.get("evidence", "")
            elif rec.get("proctoring_signal") is True:
                anchor["securing_kind"] = "identity"
                anchor["securing_evidence"] = "proctoring_signal (API fact)"
            anchors.append(anchor)

        if lane == "mismatch":
            mismatch_items.append({"id": rid, "title": rec.get("title", ""), "grade_weight_pct": w})

    # Clamp: weights can sum >100 on a malformed inventory; coverage is a share.
    coverage_pct = min(100.0, sum((a["grade_weight_pct"] for a in anchors), 0.0))
    anchor_count = len(anchors)
    has_mismatch = len(mismatch_items) > 0

    # Tier assignment (deterministic -- no AI field feeds this). (#219) Coverage-
    # derived only; has_mismatch is intentionally NOT part of this decision.
    if coverage_pct >= ASSURED_COVERAGE_THRESHOLD and anchor_count >= 1:
        tier = AssuranceTier.ASSURED
    elif coverage_pct > 0.0:
        tier = AssuranceTier.PARTIALLY_ASSURED
    else:
        tier = AssuranceTier.UNASSURED

    return {
        "coverage_pct": round(coverage_pct, 4),
        "tier": tier,
        "anchor_count": anchor_count,
        "anchor_items": anchors,
        "mismatch_items": mismatch_items,
        "has_mismatch": has_mismatch,
    }


# ---------------------------------------------------------------------------
# Top-K selection for AI annotation scope cap
# ---------------------------------------------------------------------------

def _select_top_k(records: list[dict]) -> tuple[list[dict], int, float]:
    """Select the top-K assessments by grade_weight_pct covering ~80% of weight.

    Returns (top_k_records, K, actual_weight_covered_pct).
    """
    # Sort descending by weight
    sorted_recs = sorted(records, key=lambda r: r.get("grade_weight_pct") or 0.0, reverse=True)

    total_weight = sum(r.get("grade_weight_pct") or 0.0 for r in records)
    target = total_weight * TOP_K_WEIGHT_TARGET

    selected = []
    accumulated = 0.0
    for rec in sorted_recs:
        w = rec.get("grade_weight_pct") or 0.0
        selected.append(rec)
        accumulated += w
        if accumulated >= target:
            break

    k = len(selected)
    covered_pct = round((accumulated / total_weight * 100.0) if total_weight > 0 else 0.0, 2)
    return selected, k, covered_pct


# ---------------------------------------------------------------------------
# AI annotation packaging (Component 4)
# ---------------------------------------------------------------------------

def _content_hash(text: str) -> str:
    """Return a stable sha256 hex digest of the given text (for judgment caching)."""
    return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()


def _package_ai_items(
    top_k_records: list[dict],
    syllabus_text: str,
    lanes: dict[int, str],
    aias_result: dict,
) -> list[dict]:
    """Package AI judgment requests for the top-K assessments.

    This module never calls an LLM -- it produces a list of judgment items
    for the caller (Claude) to evaluate.  Mirrors criterion_evaluator's
    needs_ai_review discipline without importing it.

    Each item:
        {
            "id": int,
            "name": str,
            "points": float,
            "grade_weight_pct": float,
            "question": str,          # the assessment title (used as prompt surface)
            "judgment_type": str,     # see JUDGMENT_TYPES below
            "prompt_version": str,
            "content_hash": str,      # sha256(question) for caching
            "label": str,             # "within-course diagnostic, not cross-course comparable"
        }

    Judgment types:
        authenticity_5df        -- Gulikers 5DF authenticity assessment
        substitutability        -- can AI substitute for this Lane 1 assessment?
        fire_quality            -- FIRE (Focused/Integrative/Reflective/Expansive) for Lane 2
        aias_level              -- AI-use policy integration level 1-5
        evaluative_judgment     -- requires evaluative judgment (AIAS 4-5 context)
        counterfeit_freeresponse -- free-response claiming high Bloom that type cannot settle
        judgment_of_ai          -- AI-Forward D4: does the task grade the quality of AI-use judgment?
        authorship_evidence_depth -- AI-Forward D5: does process evidence document prompts/revisions/verification?
        counterfeit_objective_mlo -- objective quiz in a module whose MLOs demand Analyze+: counterfeit or legit concept check?
    """
    items: list[dict] = []

    for rec in top_k_records:
        rid = rec["id"]
        name = rec.get("title") or f"Assessment {rid}"
        pts = rec.get("points") or 0.0
        w = rec.get("grade_weight_pct") or 0.0
        lane = lanes.get(rid, "unclassified")
        engine = rec.get("engine", "")
        canvas_type = rec.get("canvas_type", "")
        question_text = name  # Title is our best content surface without question API

        def _make_item(judgment_type: str, question: str) -> dict:
            return {
                "id": rid,
                "name": name,
                "points": pts,
                "grade_weight_pct": w,
                "question": question,
                "judgment_type": judgment_type,
                "prompt_version": PROMPT_VERSION,
                "content_hash": _content_hash(question),
                "label": "within-course diagnostic, not cross-course comparable",
            }

        # Always: authenticity (Gulikers 5DF)
        items.append(_make_item("authenticity_5df", question_text))

        # Lane 1: substitutability
        if lane == "lane1":
            items.append(_make_item("substitutability", question_text))

        # Lane 2: FIRE quality
        if lane == "lane2":
            items.append(_make_item("fire_quality", question_text))

        # AIAS level (for all top-K items, since the policy may name specific assignments)
        items.append(_make_item("aias_level", question_text))

        # AIAS 4-5 evaluative judgment: only if policy declared
        if aias_result.get("declared_presence"):
            items.append(_make_item("evaluative_judgment", question_text))

        # Counterfeit free-response: for non-quiz upload/essay types claiming Analyze+
        is_quiz = _is_quiz(rec)
        sub_types = set(rec.get("submission_types") or [])
        is_free_response = not is_quiz and bool(sub_types & _UNCONTROLLED_SUBMISSION_TYPES)
        _, level, bloom_source = _rec_bloom(rec)
        high_bloom = level in BLOOMS_LEVELS and BLOOMS_LEVELS[level] >= COUNTERFEIT_OBJECTIVE_BLOOM_THRESHOLD
        if is_free_response and high_bloom:
            items.append(_make_item("counterfeit_freeresponse", question_text))

        # MLO-grounded counterfeit check: an objective quiz sitting in a module
        # whose MLOs demand Analyze+ may be measuring less than the module claims,
        # OR may be a legitimate low-stakes concept check. AI judges which.
        # Criterion B: also emit for bloom_mlo_capped items -- the original MLO was
        # Analyze+ but the level was capped at Understand; AI must confirm.
        bloom_mlo_capped = rec.get("bloom_mlo_capped", False)
        if is_quiz and bloom_source == "mlo" and (high_bloom or bloom_mlo_capped):
            items.append(_make_item("counterfeit_objective_mlo", question_text))

        # AI-Forward refinements (narrative only; never feed the deterministic
        # ai_forward_rating). D4 judgment-of-AI and D5 authorship-evidence depth.
        items.append(_make_item("judgment_of_ai", question_text))
        items.append(_make_item("authorship_evidence_depth", question_text))

    return items


# ---------------------------------------------------------------------------
# Flags
# ---------------------------------------------------------------------------

def _compute_flags(
    coverage: dict,
    aias_result: dict,
    lanes: dict[int, str],
    records: list[dict],
) -> dict:
    """Compute boolean flags.

    Returns:
        {
            "counterfeit_assurance": bool,  # (#219) mismatch/counterfeit-claim
                                             # present; layered flag, no longer
                                             # tied to tier == Counterfeit (which
                                             # is deprecated-not-assigned).
            "transparency_gap":      bool,  # No AIAS policy declared
            "no_anchor":             bool,  # Zero secured Lane 1 anchors
            "over_surveillance":     bool,  # Lane 1 covers >80% of weight
        }
    """
    counterfeit = coverage.get("has_mismatch", False)
    transparency_gap = not aias_result.get("declared_presence", False)
    no_anchor = coverage["anchor_count"] == 0
    lane1_weight = sum(
        (rec.get("grade_weight_pct") or 0.0)
        for rec in records
        if lanes.get(rec["id"]) == "lane1"
    )
    over_surveillance = lane1_weight > OVER_SURVEILLANCE_THRESHOLD

    return {
        "counterfeit_assurance": counterfeit,
        "transparency_gap": transparency_gap,
        "no_anchor": no_anchor,
        "over_surveillance": over_surveillance,
    }


# ---------------------------------------------------------------------------
# Equity and Feasibility (Dimension E) -- deterministic, per-course only
# ---------------------------------------------------------------------------

def _compute_equity_feasibility(records: list[dict], lanes: dict) -> dict:
    """Compute equity and feasibility burdens for a 7.5-week fully-online cohort.

    Three burden types (each cites assessments with weights):
      surveillance         -- proctored items sum >= SURVEILLANCE_HIGH_BURDEN_PCT
      synchronous          -- oral/live Lane 1 items count >= SYNC_ANCHOR_COUNT
      high_stakes_concentration -- items each >= HIGH_WEIGHT_THRESHOLD sum >= CONCENTRATION_PCT

    handgraded_anchor_count is reported informational only; it does NOT drive a burden
    because enrollment data is unavailable to assert a labor claim.

    Returns:
        {
            "feasibility_band":            "low_burden" | "moderate_burden" | "high_burden",
            "surveillance_weight_pct":     float,   # proctoring_signal=True items only
            "synchronous_anchor_count":    int,
            "synchronous_weight_pct":      float,
            "handgraded_anchor_count":     int,      # informational only
            "high_stakes_concentration_pct": float,
            "burdens":                     list[dict],
            "label":                       str,
        }
    """
    # -- Surveillance burden (proctoring-only, NOT lane1 weight) --
    # Criterion D: only confirmed True counts as proctored; "unknown" does not.
    proctored_items = [
        rec for rec in records if rec.get("proctoring_signal") is True
    ]
    surveillance_weight = sum(
        (rec.get("grade_weight_pct") or 0.0) for rec in proctored_items
    )
    surveillance_weight_pct = round(surveillance_weight, 2)

    # -- Synchronous burden (oral/live anchors) --
    # Post criterion-A: oral keyword items are no longer Lane 1 deterministically.
    # Synchronous burden still fires when oral-keyword items exist (they represent
    # potential scheduling burden regardless of confirmation) OR when proctored
    # items have oral signals in their title. We detect via keyword scan directly
    # rather than requiring lane1 status, since the burden question is independent
    # of the assurance classification question.
    sync_items = [
        rec for rec in records
        if _keyword_hit((rec.get("title") or "").lower(), _ORAL_KEYWORDS)
        and (rec.get("grade_weight_pct") or 0.0) > 0.0
    ]
    synchronous_anchor_count = len(sync_items)
    synchronous_weight_pct = round(
        sum((rec.get("grade_weight_pct") or 0.0) for rec in sync_items), 2
    )

    # -- Hand-graded anchor count (informational only, never a burden) --
    handgraded_anchor_count = sum(
        1 for rec in records
        if lanes.get(rec["id"]) == "lane1" and not _is_quiz(rec)
    )

    # -- High-stakes concentration burden --
    high_stakes_items = [
        rec for rec in records
        if (rec.get("grade_weight_pct") or 0.0) >= HIGH_WEIGHT_THRESHOLD
    ]
    high_stakes_concentration_pct = round(
        sum((rec.get("grade_weight_pct") or 0.0) for rec in high_stakes_items), 2
    )

    # -- Build burden list --
    burdens: list[dict] = []

    if surveillance_weight_pct >= SURVEILLANCE_HIGH_BURDEN_PCT:
        burdens.append({
            "type": "surveillance",
            "detail": (
                f"Proctored assessment weight is {surveillance_weight_pct}%, "
                f"at or above the {SURVEILLANCE_HIGH_BURDEN_PCT}% threshold. "
                "Proctoring carries equity costs: bandwidth, private testing space, "
                "disability accommodations, and test anxiety."
            ),
            "items": [
                {
                    "id": rec["id"],
                    "title": rec.get("title") or "",
                    "grade_weight_pct": rec.get("grade_weight_pct") or 0.0,
                }
                for rec in proctored_items
            ],
        })

    if synchronous_anchor_count >= SYNC_ANCHOR_COUNT:
        burdens.append({
            "type": "synchronous",
            "detail": (
                f"{synchronous_anchor_count} live/oral Lane 1 anchor(s) "
                f"({synchronous_weight_pct}% weight). "
                "Scheduling live components against an asynchronous, time-impoverished cohort "
                "creates access inequity; consider sampling rather than requiring all students."
            ),
            "items": [
                {
                    "id": rec["id"],
                    "title": rec.get("title") or "",
                    "grade_weight_pct": rec.get("grade_weight_pct") or 0.0,
                }
                for rec in sync_items
            ],
        })

    if high_stakes_concentration_pct >= CONCENTRATION_PCT:
        burdens.append({
            "type": "high_stakes_concentration",
            "detail": (
                f"{high_stakes_concentration_pct}% of grade weight is concentrated in "
                f"high-stakes items (each >= {HIGH_WEIGHT_THRESHOLD}%). "
                "Concentrated high-stakes weight is fragile in a 7.5-week term; "
                "distributed low-stakes practice improves performance "
                "(Hattie formative assessment d=0.90)."
            ),
            "items": [
                {
                    "id": rec["id"],
                    "title": rec.get("title") or "",
                    "grade_weight_pct": rec.get("grade_weight_pct") or 0.0,
                }
                for rec in high_stakes_items
            ],
        })

    # -- Band --
    n = len(burdens)
    if n >= 2:
        band = "high_burden"
    elif n == 1:
        band = "moderate_burden"
    else:
        band = "low_burden"

    return {
        "feasibility_band": band,
        "surveillance_weight_pct": surveillance_weight_pct,
        "synchronous_anchor_count": synchronous_anchor_count,
        "synchronous_weight_pct": synchronous_weight_pct,
        "handgraded_anchor_count": handgraded_anchor_count,
        "high_stakes_concentration_pct": high_stakes_concentration_pct,
        "burdens": burdens,
        "label": (
            "deterministic within-course feasibility diagnostic; "
            "not a cross-course ranking axis"
        ),
    }


def _build_dimension_e(equity: dict) -> dict:
    """Dimension E: Equity and Feasibility -- deterministic, per-course only.

    Mirrors the shape and voice of _build_dimension_c (deterministic, no AI note).
    handgraded_anchor_count is informational; it does not drive the band or any burden.
    """
    return {
        "description": (
            "Equity and Feasibility: surveillance/synchronous/concentration burdens "
            "are deterministic; handgraded_anchor_count is informational only "
            "(enrollment data needed to assert a labor burden, which is out of scope). "
            "This dimension never changes tier and is not cross-course comparable."
        ),
        "label": equity["label"],
        "feasibility_band": equity["feasibility_band"],
        "surveillance_weight_pct": equity["surveillance_weight_pct"],
        "synchronous_anchor_count": equity["synchronous_anchor_count"],
        "synchronous_weight_pct": equity["synchronous_weight_pct"],
        "handgraded_anchor_count": equity["handgraded_anchor_count"],
        "high_stakes_concentration_pct": equity["high_stakes_concentration_pct"],
        "burdens": equity["burdens"],
    }


# ---------------------------------------------------------------------------
# AI-Forward axis computation (deterministic)
# ---------------------------------------------------------------------------

def _kw_hit(text_lower: str, keywords) -> bool:
    return any(kw in text_lower for kw in keywords)


def _aiforward_item_anchor_d4(title_lower: str) -> int:
    """D4 (human judgment and AI literacy) anchor for one item, from its title."""
    has_judgment = _kw_hit(title_lower, _JUDGMENT_VERB_KEYWORDS)
    has_ai = _kw_hit(title_lower, _AI_REFERENCE_KEYWORDS)
    if has_judgment and has_ai:
        return 3  # grades the quality of AI-use judgment
    if has_judgment:
        return 2  # requires the student's own judgment/defense
    if _kw_hit(title_lower, _WEAK_REASONING_KEYWORDS):
        return 1  # some reasoning, no explicit judgment criteria
    return 0


def _aiforward_item_anchor_d5(title_lower: str) -> int:
    """D5 (process, feedback, authorship evidence) anchor for one item."""
    has_ai_doc = _kw_hit(title_lower, _AI_DOC_KEYWORDS)
    has_process = _kw_hit(title_lower, _PROCESS_TRAIL_KEYWORDS)
    if has_ai_doc and has_process:
        return 3  # documents prompts, revisions, verification, reflection
    if has_ai_doc:
        return 3  # an AI appendix is itself authorship+AI documentation
    if has_process:
        return 2  # drafts/portfolio/journal/peer review support authorship
    if any(kw in title_lower for kw in ("reflection", "draft", "revision")):
        return 1
    return 0


def _weighted_anchor(per_item: list[tuple[int, float]]) -> int:
    """Grade-weight-weighted mean anchor, rounded to the nearest integer 0-3.

    per_item is a list of (anchor, weight) tuples. Falls back to a simple mean
    when no weights are present. Empty -> 0.
    """
    if not per_item:
        return 0
    total_w = sum(w for _, w in per_item)
    if total_w > 0:
        avg = sum(a * w for a, w in per_item) / total_w
    else:
        avg = sum(a for a, _ in per_item) / len(per_item)
    return max(0, min(3, int(round(avg))))


def _compute_ai_forward(
    records: list[dict],
    lanes: dict[int, str],
    coverage: dict,
    cognitive: dict,
    equity: dict,
    aias_result: dict,
) -> dict:
    """Compute the AI-Forward rating and its six dimension anchors.

    Returns:
        {
            "rating": str,                 # AI_FORWARD_ANCHORS value
            "rating_index": int,           # 0-3
            "auto_flagged": bool,          # high-stakes item with no anchor floored the rating
            "dimensions": {D1..D6: {anchor, label, basis}},
            "note": str,
        }

    Deterministic. AIAS *level* and the D4/D5 AI refinements (needs_ai_review)
    never feed this rating; they enrich the narrative only.
    """
    # -- D1 transparency / ethical boundaries (deterministic) --
    # 0 undeclared; 2 declared policy present; 3 when task-level AI integration is
    # evidenced (AI appendix / prompt log / verification log = disclosure + verification
    # + accountability). AIAS level 1-5 itself is AI-judged and reported separately.
    has_ai_integration = any(
        _kw_hit((r.get("title") or "").lower(), _AI_DOC_KEYWORDS) for r in records
    )
    if not aias_result.get("declared_presence"):
        d1 = 0
    elif has_ai_integration:
        d1 = 3
    else:
        d1 = 2

    # -- D2 Bloom / outcome alignment (from cognitive demand) --
    # Counterfeit objective items mean the Bloom claim is inflated -> safeguards.
    d2 = 1 if cognitive.get("counterfeit_objective") else 2

    # -- D3 AI-resistant assurance anchor (mirrors the assurance tier) --
    _tier_to_anchor = {
        AssuranceTier.COUNTERFEIT: 0,
        AssuranceTier.UNASSURED: 0,
        AssuranceTier.PARTIALLY_ASSURED: 1,
        AssuranceTier.ASSURED: 2,
    }
    d3 = _tier_to_anchor.get(coverage["tier"], 0)

    # -- D4 human judgment and AI literacy (weight-weighted per-item) --
    d4_items = [
        (_aiforward_item_anchor_d4((r.get("title") or "").lower()),
         r.get("grade_weight_pct") or 0.0)
        for r in records
    ]
    d4 = _weighted_anchor(d4_items)

    # -- D5 process, feedback, authorship evidence (weight-weighted per-item) --
    d5_items = [
        (_aiforward_item_anchor_d5((r.get("title") or "").lower()),
         r.get("grade_weight_pct") or 0.0)
        for r in records
    ]
    d5 = _weighted_anchor(d5_items)

    # -- D6 equity / workload / 7.5-week feasibility (from Dimension E band) --
    _band_to_anchor = {"high_burden": 1, "moderate_burden": 2, "low_burden": 3}
    d6 = _band_to_anchor.get(equity.get("feasibility_band"), 2)

    # -- Overall rating: the three "forward" dimensions (D1, D4, D5), weakest link --
    # Rubric: "use the category that best describes the current design, not the
    # aspirational intent" -> conservative min across the forward dimensions.
    rating_index = min(d1, d4, d5)

    # -- Auto-rule: high-stakes task with no assurance anchor -> Needs redesign --
    # Rubric: "High-stakes tasks with no assurance anchor are automatically flagged."
    # Fires when the course cannot certify (Unassured = no anchor at all, or
    # Counterfeit = a high-stakes item is unanchored even if other anchors exist).
    has_unanchored_high_stakes = any(
        (r.get("grade_weight_pct") or 0.0) >= HIGH_WEIGHT_THRESHOLD
        and lanes.get(r["id"]) in ("mismatch", "lane2", "unclassified")
        for r in records
    )
    # (#219) tier is coverage-derived only and never equals COUNTERFEIT anymore,
    # so the auto-rule now also fires on has_mismatch (the former Counterfeit
    # trigger) directly, in addition to the Unassured tier.
    auto_flagged = has_unanchored_high_stakes and (
        coverage["tier"] == AssuranceTier.UNASSURED or coverage.get("has_mismatch", False)
    )
    if auto_flagged:
        rating_index = 0

    def _dim(anchor: int, basis: str) -> dict:
        return {"anchor": anchor, "label": AI_FORWARD_ANCHORS[anchor], "basis": basis}

    return {
        "rating": AI_FORWARD_ANCHORS[rating_index],
        "rating_index": rating_index,
        "auto_flagged": auto_flagged,
        "dimensions": {
            "D1_transparency": _dim(d1, "AIAS policy presence (level 1-5 is AI-judged, reported separately)"),
            "D2_bloom_alignment": _dim(d2, "counterfeit objective items inflate the Bloom claim" if d2 == 1 else "Bloom claim not contradicted by objective auto-grading"),
            "D3_assurance_anchor": _dim(d3, f"mirrors assurance tier: {coverage['tier']}"),
            "D4_human_judgment": _dim(d4, "weight-weighted judgment-of-AI signals in assessment titles"),
            "D5_process_authorship": _dim(d5, "weight-weighted process-trail and AI-documentation signals"),
            "D6_equity_feasibility": _dim(d6, f"Dimension E band: {equity.get('feasibility_band')}"),
        },
        "note": (
            "AI-Forward rating is deterministic and orthogonal to the assurance tier. "
            "Overall rating is the weakest of the three forward dimensions (D1 transparency, "
            "D4 judgment, D5 authorship); D2/D3/D6 are reported for completeness. "
            "High-stakes items with no assurance anchor auto-floor the rating to Needs redesign. "
            "AIAS level and the D4/D5 AI refinements (needs_ai_review) never change this rating."
        ),
    }


# ---------------------------------------------------------------------------
# Bloom-calibrated findings (deterministic)
# ---------------------------------------------------------------------------

def _compute_bloom_calibration_findings(records: list[dict]) -> list[dict]:
    """Per high-weight assessment, apply the rubric's Bloom Guide "Avoid" rules.

    Deterministic, from signals the inventory actually carries (Bloom level by
    title, quiz/objective type, proctoring signal, rubric presence, process-trail
    keyword, grade weight). Each finding cites assessment + points + grade_weight_pct.

    Note: question-bank randomization and attempt limits are not exposed by the
    inventory, so recall findings name that as a caveat rather than asserting it.
    """
    findings: list[dict] = []

    for rec in records:
        w = rec.get("grade_weight_pct") or 0.0
        if w < HIGH_WEIGHT_THRESHOLD:
            continue
        title = rec.get("title") or ""
        title_lower = title.lower()
        pts = rec.get("points") or 0.0
        _, level, _bloom_source = _rec_bloom(rec)
        is_quiz = _is_quiz(rec)
        # Tri-state (True / False / "unknown"): only a confirmed True is
        # proctored, matching every other consumer (~lines 348, 402, 881).
        # bool("unknown") is True, so a plain bool() would silently mark an
        # unconfirmable quiz as protected and suppress its finding.
        proctored = rec.get("proctoring_signal") is True
        has_rubric = bool(rec.get("has_rubric", False))
        has_process = _kw_hit(title_lower, _PROCESS_TRAIL_KEYWORDS)

        def _finding(rule: str, message: str) -> dict:
            return {
                "assessment": title,
                "id": rec["id"],
                "points": pts,
                "grade_weight_pct": w,
                "bloom_level": level,
                "rule": rule,
                "message": message,
            }

        if level in ("Remember", "Understand") and is_quiz and not proctored:
            findings.append(_finding(
                "recall_unprotected",
                f"High-stakes {level}-level auto-graded quiz ({w:.0f}% weight) with no "
                "proctoring signal. Recall/understand items are AI-pasteable; protect with "
                "a large randomized item bank, short windows, and limited attempts, or lower "
                "the stakes. (Randomization and attempt limits are not visible to this audit; "
                "verify them in Canvas.)"
            ))
        elif level == "Apply" and is_quiz and not proctored:
            findings.append(_finding(
                "apply_reusable",
                f"High-stakes Apply-level auto-graded item ({w:.0f}% weight). Reusable "
                "textbook-style problems are AI-solvable; require unique variant data, visible "
                "work steps, or a short rationale question."
            ))
        elif level in ("Analyze", "Evaluate") and not has_rubric:
            findings.append(_finding(
                "highorder_no_criteria",
                f"High-stakes {level}-level item ({w:.0f}% weight) has no rubric. "
                "Higher-order judgment needs explicit criteria, tradeoff analysis, and a "
                "defense against counterarguments; without criteria it is graded on fluency."
            ))
        elif level == "Create" and not has_process:
            findings.append(_finding(
                "create_oneshot",
                f"High-stakes Create-level item ({w:.0f}% weight) shows no staged-process "
                "signal (proposal, draft, feedback, defense, portfolio). One-shot final products "
                "with no milestones or disclosure are AI-substitutable; stage the project and add "
                "a short recorded defense or AI-use appendix."
            ))

    return findings


# ---------------------------------------------------------------------------
# Dimension builders
# ---------------------------------------------------------------------------

def _build_dimension_a(top_k_records: list[dict], k: int) -> dict:
    """Dimension A: Authenticity Index (Gulikers 5DF).

    Deterministic tiering from item properties; detailed scoring requires
    AI annotation (see needs_ai_review items with judgment_type='authenticity_5df').
    """
    # Deterministic proxy: rubric presence correlates with authentic task design
    rubric_count = sum(1 for r in top_k_records if r.get("has_rubric"))
    rubric_pct = round(rubric_count / k * 100.0, 1) if k > 0 else 0.0

    return {
        "description": "Authenticity Index (Gulikers 5DF) -- AI-judged top-K, deterministic tiering",
        "label": "within-course diagnostic, not cross-course comparable",
        "top_k_assessed": k,
        "rubric_present_pct": rubric_pct,
        "rubric_present_count": rubric_count,
        "note": (
            "Full 5DF scoring (real-world task, physical/social fidelity, "
            "cognitive complexity, result/form, criteria) is in needs_ai_review "
            "items with judgment_type=authenticity_5df."
        ),
    }


def _build_dimension_b(records: list[dict]) -> dict:
    """Dimension B: Learning Science -- pointer/deltas only, NOT scored.

    Emits at most 1-2 assurance-specific deltas and a pointer to the
    `alignment` skill + HPI/ID standard.  Does not duplicate the
    learning-science corpus.
    """
    deltas = []

    # Delta: high-weight summative with no formative practice loop
    high_weight_summative = [
        r for r in records
        if (r.get("grade_weight_pct") or 0.0) >= HIGH_WEIGHT_THRESHOLD
        and not r.get("has_rubric")
    ]
    if high_weight_summative:
        items_str = "; ".join(
            f"{r['title']} ({r['grade_weight_pct']:.1f}%)"
            for r in high_weight_summative[:3]
        )
        deltas.append({
            "finding": (
                "Terminal-performance test: high-weight summative item(s) lack a "
                "rubric, which signals no explicit practice-feedback loop. "
                f"Items: {items_str}."
            ),
            "hpi_principle": (
                "Gilbert BEM: missing Information (rubric = performance standard) "
                "is an environment-side gap. Mager+Pipe: will/skill gap not "
                "diagnosable without a stated performance standard."
            ),
        })

    return {
        "description": "Learning Science lens -- pointer only, not a scored dimension",
        "scored": False,
        "deltas": deltas,
        "pointer": (
            "Full constructive-alignment analysis: use the `alignment` skill "
            "(Biggs alignment + CLO/MLO/assessment graph). "
            "Full HPI diagnosis: apply Gilbert BEM + Mager+Pipe per the "
            "HPI/ID standard in standards/instructional-design.md."
        ),
    }


def _build_dimension_c(cognitive: dict, lanes: dict[int, str]) -> dict:
    """Dimension C: Cognitive Demand -- deterministic."""
    return {
        "description": "Cognitive Demand: Bloom distribution + arc + counterfeit detection",
        "weighted_distribution": cognitive["weighted_distribution"],
        "arc": cognitive["arc"],
        "surface_above_50pct_flag": cognitive["surface_above_50pct_flag"],
        "surface_above_70pct_flag": cognitive["surface_above_70pct_flag"],
        "counterfeit_objective": cognitive["counterfeit_objective"],
        "counterfeit_objective_mlo": cognitive.get("counterfeit_objective_mlo", []),
        "bloom_source_breakdown": cognitive.get("bloom_source_breakdown", {}),
        "note": (
            "Bloom levels use the assessment title verb when present and fall back to "
            "the module's MLOs (bloom_source_breakdown shows the split). Counterfeit "
            "free-response and MLO-grounded objective-quiz items require AI judgment "
            "(needs_ai_review: counterfeit_freeresponse, counterfeit_objective_mlo)."
        ),
    }


def _build_dimension_d(
    coverage: dict,
    aias_result: dict,
    lanes: dict[int, str],
    records: list[dict],
    equity: dict,
) -> dict:
    """Dimension D: Assurance & AI-Integration -- headline dimension."""
    lane_counts: dict[str, int] = {"lane1": 0, "lane2": 0, "mismatch": 0, "unclassified": 0}
    lane_weight: dict[str, float] = {"lane1": 0.0, "lane2": 0.0, "mismatch": 0.0, "unclassified": 0.0}
    for rec in records:
        lane = lanes.get(rec["id"], "unclassified")
        lane_counts[lane] = lane_counts.get(lane, 0) + 1
        lane_weight[lane] = lane_weight.get(lane, 0.0) + (rec.get("grade_weight_pct") or 0.0)

    return {
        "description": (
            "Assurance and AI-Integration: Lane/AIAS-presence/Coverage/tier "
            "are deterministic; substitutability/FIRE/AIAS-level require AI annotation "
            "(see needs_ai_review)."
        ),
        "tier": coverage["tier"],
        "assurance_coverage_pct": coverage["coverage_pct"],
        "anchor_count": coverage["anchor_count"],
        "anchor_items": coverage["anchor_items"],
        "mismatch_items": coverage["mismatch_items"],
        "lane_summary": {
            "lane1_count": lane_counts["lane1"],
            "lane2_count": lane_counts["lane2"],
            "mismatch_count": lane_counts["mismatch"],
            "unclassified_count": lane_counts["unclassified"],
            "lane1_weight_pct": round(lane_weight["lane1"], 2),
            "lane2_weight_pct": round(lane_weight["lane2"], 2),
            "mismatch_weight_pct": round(lane_weight["mismatch"], 2),
        },
        "aias": {
            "declared_presence": aias_result["declared_presence"],
            "found_in": aias_result["found_in"],
            "policy_excerpts": aias_result.get("policy_excerpts", []),
            "note": (
                "AIAS level 1-5 classification is AI-judged (within-course diagnostic). "
                "policy_excerpts carries the matched text spans for Claude's inline read. "
                "See needs_ai_review items with judgment_type=aias_level."
            ),
        },
        "recommendations": _build_recommendations(coverage, aias_result, equity),
    }


def _build_recommendations(coverage: dict, aias_result: dict, equity: dict) -> list[str]:
    """Build a short list of actionable recommendations (deterministic).

    Recommendation vocabulary: secure, re-lane, declare, liberate.
    Feasibility-aware: when equity burdens are high, prefer lower-burden mechanisms.
    No additional-proctoring framing when surveillance burden is already high.
    """
    recs = []
    tier = coverage["tier"]
    surveillance_pct = equity.get("surveillance_weight_pct", 0.0)
    sync_count = equity.get("synchronous_anchor_count", 0)
    high_surveillance = surveillance_pct >= SURVEILLANCE_HIGH_BURDEN_PCT
    high_sync = sync_count >= SYNC_ANCHOR_COUNT

    if tier == AssuranceTier.COUNTERFEIT:
        recs.append(
            "Re-lane: high-weight assessments lack a verifiable security property. "
            "Add an authenticated component (oral defense, proctored component, "
            "or process portfolio) to each mismatch item."
        )
    elif tier == AssuranceTier.UNASSURED:
        if high_surveillance:
            recs.append(
                "Secure: course has no Lane 1 anchors. "
                "Prefer a process-trail or recorded asynchronous defense to establish "
                "a performance anchor; current surveillance burden is "
                + str(surveillance_pct) + "%, so lower-burden mechanisms are preferred."
            )
        else:
            recs.append(
                "Secure: course has no Lane 1 anchors. "
                "Introduce at least one high-weight authenticated component "
                "(oral, proctored, or process-trail) to establish a performance anchor."
            )
        if high_sync:
            recs.append(
                "Secure (synchronous burden): sample oral/live components rather than "
                "requiring them of all students, given "
                + str(sync_count) + " live/oral anchor(s) in the course."
            )
    elif tier == AssuranceTier.PARTIALLY_ASSURED:
        if high_surveillance:
            recs.append(
                "Secure: expand the secured anchor weight to 40%+ to reach the Assured tier. "
                "Prefer a process-trail or recorded asynchronous defense; current surveillance "
                "burden is " + str(surveillance_pct) + "%, so lower-burden mechanisms are preferred."
            )
        else:
            recs.append(
                "Secure: expand the secured anchor weight to 40%+ "
                "to reach the Assured tier. Consider converting one uncontrolled "
                "high-weight item to a process-trail or oral component."
            )
        if high_sync:
            recs.append(
                "Secure (synchronous burden): sample oral/live components rather than "
                "requiring them of all students, given "
                + str(sync_count) + " live/oral anchor(s) in the course."
            )

    if not aias_result.get("declared_presence"):
        recs.append(
            "Declare: no AI-use policy detected in syllabus or assignment descriptions. "
            "Add explicit AI-use guidance (AIAS level 2+ minimum) to the syllabus "
            "and to each high-weight assignment."
        )

    # Standalone high-burden rec (names dominant burden and lowest-burden alternative)
    if equity.get("feasibility_band") == "high_burden":
        burden_types = [b["type"] for b in equity.get("burdens", [])]
        dominant = burden_types[0] if burden_types else "combined"
        recs.append(
            "Feasibility: course is rated high_burden (2+ equity burdens: "
            + ", ".join(burden_types) + "). "
            "Consider the lowest-burden alternative that still certifies learning: "
            "process-trail portfolios and asynchronous recorded defenses carry less "
            "scheduling and proctoring burden than synchronous live or proctored components."
        )

    if not recs:
        recs.append(
            "Course meets Assured tier threshold. "
            "Review needs_ai_review items for within-course authenticity diagnostics."
        )

    return recs


# ---------------------------------------------------------------------------
# Durable skills inference (deterministic heuristic)
# ---------------------------------------------------------------------------

def _infer_durable_skills(records: list[dict]) -> list[str]:
    """Heuristic inference of durable-skill signals from assessment titles.

    Returns a deduplicated list of skill labels found.
    """
    _SKILL_PATTERNS = [
        (r"\boral\b|\bpresent|\bdefens|\bviva\b|\bmoot\b", "oral communication"),
        (r"\bnegotiat|\bdisput|\bmediat", "dispute resolution / negotiation"),
        (r"\bportfolio\b|\breflect|\bjournal", "reflective practice / portfolio"),
        (r"\bpeer\b|\bcollabor|\bteam\b|\bgroup\b", "collaborative work"),
        (r"\bclini|\bfieldwork|\bpracticum\b|\bplacement\b", "clinical / fieldwork practice"),
        (r"\bwriting\b|\bbrief\b|\bmemo\b|\bessay\b|\breport\b", "professional writing"),
        (r"\bresearch\b|\banalys|\binvestigat", "research and analysis"),
        (r"\bexam\b|\bquiz\b|\btest\b|\bfinal\b", "timed performance under conditions"),
        (r"\bdraft\b|\brevision\b|\bfeedback\b", "iterative revision and feedback"),
    ]

    found: set[str] = set()
    for rec in records:
        title_lower = (rec.get("title") or "").lower()
        for pattern, label in _SKILL_PATTERNS:
            if re.search(pattern, title_lower):
                found.add(label)

    return sorted(found)


# ---------------------------------------------------------------------------
# Syllabus + description fetching
# ---------------------------------------------------------------------------

def _fetch_syllabus_text(config: dict, prefetched_html: str | None = None) -> str:
    """Fetch the course syllabus body and return plain text.

    If prefetched_html is given, reuse it instead of issuing a Canvas call.
    build_inventory() already requests include[]=syllabus_body on its course
    flags GET and exposes the result as inventory['syllabus_html']; passing
    that through here avoids a second, otherwise-identical /courses/:id GET.
    Callers that omit prefetched_html (or pass None) keep today's behavior
    of fetching live via canvas_api.get_syllabus.
    """
    if prefetched_html is not None:
        return re.sub(r"<[^>]+>", " ", prefetched_html)
    try:
        syllabus = canvas_api.get_syllabus(config)
        body = (syllabus or {}).get("html") or ""
        # Strip HTML tags for keyword search
        return re.sub(r"<[^>]+>", " ", body)
    except Exception as exc:
        logger.warning("Could not fetch syllabus: %s", exc)
        return ""


def _build_description_pairs(records: list[dict]) -> list[tuple[str, str]]:
    """Build (label, text) pairs from assessment records for AIAS detection.

    Uses content_excerpt when present (criterion D: description scan) so the
    AIAS prefilter searches real assignment body text, not just titles.
    Falls back to the title when no excerpt is available.
    """
    pairs = []
    for r in records:
        label = f"assignment:{r.get('title', r['id'])}"
        # Prefer full content_excerpt over title-only.
        text = r.get("content_excerpt") or r.get("title") or ""
        pairs.append((label, text))
    return pairs


# ---------------------------------------------------------------------------
# Cluster-dedup for needs_ai_review fan-out (criterion E)
# ---------------------------------------------------------------------------

def _title_cluster_key(title: str) -> str:
    """Strip index / chapter tokens to produce a cluster key.

    Handles both trailing and inline occurrences:
      "Quiz: Chapter 3"     -> "quiz: chapter"
      "SUBMIT: Ch4 Short Paper" -> "submit: ch short paper"
      "Week 5 Discussion"   -> "week discussion"
      "Week 12 Discussion"  -> "week discussion"
    Lowercased; digit tokens preceded by an optional keyword are removed anywhere
    in the string so that "Week 5" and "Week 12" both reduce to "week".
    """
    t = title.lower()
    # Remove standalone digits (possibly preceded by week/ch/chapter/module etc.)
    # anywhere in the title.
    t = re.sub(
        r"\b(?:chapter|ch|week|module|unit|section|part|quiz|exam|hw|assignment)?\s*\d+\b",
        "",
        t,
    )
    # Collapse any double spaces introduced by the substitution.
    t = re.sub(r"\s{2,}", " ", t)
    return t.strip()


def _structural_cluster_key(record: dict) -> str:
    """Return a stable cluster key based on structural metadata (not content).

    Two assessments cluster together when they share canvas_type,
    submission_types, has_rubric, and stripped title template.
    """
    sub_types_key = ",".join(sorted(record.get("submission_types") or []))
    return (
        f"{record.get('canvas_type','')}"
        f":{sub_types_key}"
        f":{record.get('has_rubric', False)}"
        f":{_title_cluster_key(record.get('title') or '')}"
    )


def _dedup_ai_items(ai_items: list[dict], records: list[dict]) -> list[dict]:
    """Cluster structurally-identical assessments and emit one item per cluster.

    For each (cluster_key x judgment_type), pick the representative item with
    the highest grade_weight_pct. Record cluster_size and cluster_members in
    the emitted item so the inline Claude verdict can apply to all members.

    Criterion E: reduces ~92 items for COM 100-style courses to roughly <= 30.
    """
    if not ai_items:
        return ai_items

    # Build an id -> record map for structural-key lookup.
    rec_by_id: dict[int, dict] = {r["id"]: r for r in records}

    # Group items by (cluster_key, judgment_type).
    from collections import defaultdict
    groups: dict[tuple[str, str], list[dict]] = defaultdict(list)
    for item in ai_items:
        rec = rec_by_id.get(item.get("id"))
        if rec is None:
            # No structural info; keep as singleton.
            groups[("_singleton_" + str(item.get("id")), item.get("judgment_type", ""))].append(item)
        else:
            ck = _structural_cluster_key(rec)
            groups[(ck, item.get("judgment_type", ""))].append(item)

    deduped: list[dict] = []
    for (ck, jtype), cluster_items in groups.items():
        if len(cluster_items) == 1:
            deduped.append(cluster_items[0])
        else:
            # Pick the representative: highest weight item.
            rep = max(cluster_items, key=lambda i: i.get("grade_weight_pct") or 0.0)
            rep = dict(rep)  # copy to avoid mutating the original
            member_ids = [i["id"] for i in cluster_items if i["id"] != rep["id"]]
            rep["cluster_size"] = len(cluster_items)
            rep["cluster_members"] = member_ids
            deduped.append(rep)

    return deduped


# ---------------------------------------------------------------------------
# Bloom-Unknown + lane_review needs_ai_review packaging (criteria B and D)
# ---------------------------------------------------------------------------

def _package_bloom_content_items(
    records: list[dict],
    top_k_ids: set[int],
) -> list[dict]:
    """Emit a bloom_content needs_ai_review item for EVERY weighted assessment (deep pass).

    Deep-pass cognitive demand: the deterministic Bloom (title verb -> MLO inheritance
    -> caps -> objective floor) under-claims demand because it never reads the task. So
    every weighted assessment carries its real prompt + rubric and Claude classifies the
    Bloom level the task REQUIRES, not the level inherited from the module objectives.

    Each item carries:
      - content_excerpt: the prompt (assignment description / discussion message / quiz stems)
      - rubric_excerpt:  the rubric criteria text (strongest cognitive-demand signal)
      - bloom_content_available: False for bank-locked objective items, tagged basis="format"
        so Claude classifies by instrument FORMAT, not by content it cannot see.

    Only emitted for non-zero-weight assessments (uncounted items skip AI budget).
    Structurally-identical items (the 10 short papers sharing one template) are deduped
    downstream by _dedup_ai_items -> one representative carries the cluster verdict.
    """
    items = []
    for rec in records:
        if (rec.get("grade_weight_pct") or 0.0) <= 0.0:
            continue
        excerpt = rec.get("content_excerpt") or ""
        rubric_excerpt = rec.get("rubric_excerpt") or ""
        # bloom_content_available defaults from attached text when the field is absent
        # (older inventories); otherwise honor the explicit flag the inventory set.
        content_available = rec.get("bloom_content_available")
        if content_available is None:
            content_available = bool(excerpt or rubric_excerpt)
        # Bank-locked objective items expose no prompt/rubric -> classify by format.
        basis = "content" if content_available else "format"
        item = {
            "id": rec["id"],
            "name": rec.get("title") or f"Assessment {rec['id']}",
            "points": rec.get("points") or 0.0,
            "grade_weight_pct": rec.get("grade_weight_pct") or 0.0,
            "question": rec.get("title") or "",
            "judgment_type": "bloom_content",
            "content_excerpt": excerpt,
            "rubric_excerpt": rubric_excerpt,
            "bloom_content_available": bool(content_available),
            "basis": basis,
            "deterministic_bloom": rec.get("bloom_level") or "Unknown",
            "deterministic_bloom_source": rec.get("bloom_source") or "unknown",
            "engine": rec.get("engine") or "",
            "prompt_version": PROMPT_VERSION,
            "content_hash": _content_hash(
                (excerpt + " " + rubric_excerpt).strip() or rec.get("title") or ""
            ),
            "label": "within-course diagnostic, not cross-course comparable",
            "bloom_source": "content_ai",  # documents that Claude sets this field
        }
        items.append(item)
    return items


def _package_lane_review_items(
    records: list[dict],
    lanes: dict[int, str],
    ai_securing: dict[int, dict] | None = None,
) -> list[dict]:
    """Emit lane_review needs_ai_review items for ambiguous assessments.

    Two cases that trigger a lane_review item (criterion A + D):

    1. Keyword-nominated lane1 candidates (criterion A): any item that matched
       oral/process-trail keywords is classified Lane 2 deterministically but
       nominated as a potential Lane 1.  A lane_review item is emitted for ALL
       such items regardless of grade weight (HIGH_WEIGHT_THRESHOLD is NOT applied
       here).  The item's `lane1_candidate` flag is set True.

    2. High-weight mismatch/unclassified items (criterion D): for high-weight
       items whose lane is mismatch or unclassified, or items with
       proctoring_signal="unknown" at high weight, carry description_excerpt so
       Claude can confirm the lane from the body.

    When an `ai_securing` entry already exists for a record, that AI
    classification is authoritative and no advisory is emitted for it (the
    entry itself carries the cited evidence) -- this applies to both cases.
    """
    items = []
    # Track IDs already emitted to avoid duplicates when an item meets both cases.
    emitted: set[int] = set()
    ai_securing = _normalize_securing_keys(ai_securing)

    for rec in records:
        rid = rec["id"]
        pct = rec.get("grade_weight_pct") or 0.0
        lane = lanes.get(rid, "unclassified")
        proc = rec.get("proctoring_signal")
        excerpt = rec.get("content_excerpt") or ""

        # An AI securing classification already exists for this item -- it is
        # authoritative, so no lane_review advisory is needed.
        if rid in ai_securing:
            continue

        # Case 1: keyword-nominated candidate (criterion A) -- any weight.
        is_kw_candidate = _is_lane1_keyword_candidate(rec)
        if is_kw_candidate and rid not in emitted:
            emitted.add(rid)
            item = {
                "id": rid,
                "name": rec.get("title") or f"Assessment {rid}",
                "points": rec.get("points") or 0.0,
                "grade_weight_pct": pct,
                "question": rec.get("title") or "",
                "judgment_type": "lane_review",
                "description_excerpt": excerpt,
                "proctoring_signal": proc,
                "current_lane": lane,
                "lane1_candidate": True,
                "prompt_version": PROMPT_VERSION,
                "content_hash": _content_hash(excerpt or rec.get("title") or ""),
                "label": "within-course diagnostic, not cross-course comparable",
            }
            items.append(item)
            continue

        # Case 2: high-weight mismatch/unclassified or unknown proctoring (criterion D).
        is_ambiguous = (
            pct >= HIGH_WEIGHT_THRESHOLD
            and (lane in ("mismatch", "unclassified") or proc == "unknown")
        )
        if is_ambiguous and rid not in emitted:
            emitted.add(rid)
            item = {
                "id": rid,
                "name": rec.get("title") or f"Assessment {rid}",
                "points": rec.get("points") or 0.0,
                "grade_weight_pct": pct,
                "question": rec.get("title") or "",
                "judgment_type": "lane_review",
                "description_excerpt": excerpt,
                "proctoring_signal": proc,
                "current_lane": lane,
                "lane1_candidate": False,
                "prompt_version": PROMPT_VERSION,
                "content_hash": _content_hash(excerpt or rec.get("title") or ""),
                "label": "within-course diagnostic, not cross-course comparable",
            }
            items.append(item)

    return items


# ---------------------------------------------------------------------------
# Security ledger: per-assessment binary labels (plan: assurance-binary-labels)
#
# Two plain per-item labels, replacing the featured coverage-%/tier headline:
#   security: "secured" | "unsecured", via identity control (proctoring, always
#     deterministic) OR authorship evidence (AI-judged, cite-or-fail: an
#     ai_securing entry only secures when it carries a non-empty evidence
#     citation). A deterministic keyword/structural nomination never itself
#     secures; absent a confirming ai_securing entry it surfaces as
#     unsecured + needs_ai_review, the honest floor.
#   design: "ai_forward" | "ai_resistant" | "n/a" (auto-graded objective items),
#     using the same D4/D5 AI-Forward signals the course-level dimensions.F axis
#     uses (so the two never disagree on what counts), but read across the title,
#     the prompt body, and the rubric rather than the title alone.
# ---------------------------------------------------------------------------

def _classify_design(record: dict) -> tuple[str, str]:
    """Return (design, design_basis) for one assessment.

    n/a for auto-graded objective instruments (quiz/exam with no free
    response): the forward/resistant axis does not apply. Otherwise applies the
    SAME D4/D5 AI-Forward signals the course-level axis uses (judgment verb +
    AI reference for D4 anchor 3; AI-doc + process trail for D5 anchor 3), but
    reads them across the TITLE, the prompt body (`content_excerpt`), AND the
    rubric (`rubric_excerpt`), not the title alone. The normal AI-forward task
    ("use the AI, then critique/verify it; submit an AI-use appendix") carries
    that instruction in the body or rubric, not the title, so a title-only read
    mislabels it ai_resistant; the security side already reads all three
    surfaces, and the design side must too. The keyword sets and the
    course-level anchor functions (`_aiforward_item_anchor_d4`/`_d5`, used by
    `_compute_ai_forward`) are intentionally left untouched. A low-confidence
    read defaults to ai_resistant; the interactive Step 2 AI pass may refine it.
    """
    if _is_quiz(record):
        return "n/a", ""

    surfaces = (
        ("title", (record.get("title") or "").lower()),
        ("prompt body", (record.get("content_excerpt") or "").lower()),
        ("rubric", (record.get("rubric_excerpt") or "").lower()),
    )

    def _found_in(keywords) -> str:
        """Name the first surface (title / prompt body / rubric) that hits, else ''."""
        for label, text in surfaces:
            if text and _kw_hit(text, keywords):
                return label
        return ""

    judgment_where = _found_in(_JUDGMENT_VERB_KEYWORDS)
    ai_where = _found_in(_AI_REFERENCE_KEYWORDS)
    ai_doc_where = _found_in(_AI_DOC_KEYWORDS)
    process_where = _found_in(_PROCESS_TRAIL_KEYWORDS)

    # ai_forward: D4 anchor 3 (grades the quality of AI-use judgment: a judgment
    # verb AND an AI reference) OR D5 anchor 3 (documents AI use for grading: an
    # AI-use appendix / prompt log / verification log). Each signal may live in
    # any of the three surfaces.
    if (judgment_where and ai_where) or ai_doc_where:
        bases = []
        if judgment_where and ai_where:
            loc = judgment_where if judgment_where == ai_where else f"{judgment_where}/{ai_where}"
            bases.append(f"grades the quality of AI-use judgment (judgment verb + AI reference in {loc})")
        if ai_doc_where:
            bases.append(f"documents AI use for grading, e.g. an AI-use appendix or prompt log ({ai_doc_where})")
        return "ai_forward", "; ".join(bases)

    if judgment_where:
        return "ai_resistant", (
            f"requires the student's own judgment or defense ({judgment_where}), no graded AI-use signal"
        )
    if process_where:
        return "ai_resistant", (
            f"process-trail/authorship signal ({process_where}) supports authorship, not AI-use"
        )

    return "ai_resistant", "low-confidence deterministic read; defaults to AI-resistant"


def _compute_security_ledger(
    records: list[dict],
    ai_securing: dict[int, dict] | None = None,
) -> list[dict]:
    """Build the per-assessment security_ledger (plan: assurance-binary-labels).

    One entry per record in `records`, hybrid detection:
      1. proctoring_signal is True -> secured, basis identity (deterministic
         API fact; an AI "unsecured" judgment can never downgrade this).
      2. else an ai_securing entry for this id with secured is True AND a
         non-empty evidence citation -> secured, basis authorship (cite-or-fail:
         a secured=True entry with no citation does NOT secure).
      3. else -> unsecured, basis none. needs_ai_review is True only here, and
         only when the deterministic nominator (_is_authorship_nominated)
         flagged the item AND no ai_securing entry exists for it at all (an
         existing entry, confirming or not, means AI already reviewed the
         item; mirrors _package_lane_review_items's same convention).
    """
    ai_securing = _normalize_securing_keys(ai_securing)
    ledger: list[dict] = []

    for rec in records:
        rid = rec["id"]
        entry = ai_securing.get(rid)
        proctored = rec.get("proctoring_signal") is True
        cited_secure = (
            isinstance(entry, dict)
            and entry.get("secured") is True
            and bool((entry.get("evidence") or "").strip())
        )

        if proctored:
            security = "secured"
            security_basis = "identity"
            security_evidence = "proctoring_signal (API fact)"
            needs_review = False
        elif cited_secure:
            security = "secured"
            security_basis = "authorship"
            security_evidence = entry.get("evidence") or ""
            needs_review = False
        else:
            security = "unsecured"
            security_basis = "none"
            security_evidence = ""
            needs_review = entry is None and _is_authorship_nominated(rec)

        design, design_basis = _classify_design(rec)

        ledger.append({
            "id": rid,
            "title": rec.get("title") or "",
            "grade_weight_pct": rec.get("grade_weight_pct") or 0.0,
            "graded": bool((rec.get("points") or 0.0) > 0),
            "security": security,
            "security_basis": security_basis,
            "security_evidence": security_evidence,
            "needs_ai_review": needs_review,
            "design": design,
            "design_basis": design_basis,
        })

    return ledger


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------

def score_course(
    config: dict,
    *,
    inventory: dict | None = None,
    ai_securing: dict[int, dict] | None = None,
) -> dict:
    """Score one course and return a scorecard dict.

    Args:
        config:      Canvas API config from canvas_api.get_config(course_id=...).
        inventory:   Pre-built dict from build_inventory().  If None, build_inventory
                     is called internally.  Providing it avoids Canvas API calls
                     (useful in tests and batch runners that pre-fetch the inventory).
        ai_securing: Optional AI securing classification, keyed by assessment id:
                     {assessment_id: {"secured": bool,
                                       "kind": "identity"|"process"|"unsecured",
                                       "evidence": str}}.
                     Produced by the AI securing pass (skills/assurance-audit):
                     read each significant assessment's prompt + rubric + the
                     syllabus and judge whether there is a real basis to trust
                     the student did the work -- identity control (proctoring,
                     LockDown) OR authorship evidence (drafts, decision logs,
                     revision history, reflection, a graded AI-use appendix).
                     When None, lane classification falls back to the
                     deterministic-only floor (proctoring_signal only).

    Returns:
        Scorecard dict (see module docstring for full schema).
    """
    canvas_api.require_course_id(config)

    # -- 1. Inventory -------------------------------------------------------
    if inventory is None:
        inventory = build_inventory(config)

    records: list[dict] = inventory.get("assessments") or []
    inventory_assumptions: list[str] = inventory.get("assumptions") or []
    course_clos: dict = inventory.get("course_clos") or {
        "clos": [], "bloom_index": 0, "bloom_level": "Unknown", "source": "none"
    }
    module_mlos: dict = inventory.get("module_mlos") or {}

    # -- 2. Syllabus (for AIAS presence) ------------------------------------
    # inventory['syllabus_html'] is only present when this inventory came
    # from build_inventory() (which now fetches it alongside course flags).
    # Pre-built inventories from callers that predate this key simply fall
    # through to a live fetch, unchanged from before.
    syllabus_text = _fetch_syllabus_text(config, prefetched_html=inventory.get("syllabus_html"))

    # -- 3. Lane classification (identity control deterministic; authorship
    #       evidence AI-judged via ai_securing when provided) ----------------
    ai_securing = _normalize_securing_keys(ai_securing)
    lanes: dict[int, str] = {
        rec["id"]: _classify_lane(rec, ai_securing.get(rec["id"])) for rec in records
    }

    lane_balance = {
        "lane1": sum(1 for v in lanes.values() if v == "lane1"),
        "lane2": sum(1 for v in lanes.values() if v == "lane2"),
        "mismatch": sum(1 for v in lanes.values() if v == "mismatch"),
        "unclassified": sum(1 for v in lanes.values() if v == "unclassified"),
    }

    # -- 4. AIAS presence (deterministic prefilter) ------------------------
    desc_pairs = _build_description_pairs(records)
    aias_result = _detect_aias_presence(syllabus_text, desc_pairs)

    # (#219) Two honest signals replace the single gameable aias_transparency_pct
    # ratio (see _detect_aias_presence docstring for why the blended ratio was
    # misleading).
    ai_policy_in_syllabus = aias_result.get("syllabus_declared", False)
    ai_declared_assessments = {
        "count": aias_result.get("assessment_declared_count", 0),
        "total": aias_result.get("assessment_assessed_count", 0),
    }

    # -- 5. Assurance Coverage + tier (deterministic, deprecated-not-featured;
    #       see security_ledger below for the featured per-assessment labels) --
    coverage = _compute_assurance_coverage(records, lanes, ai_securing)

    # -- 5b. Security ledger: per-assessment secured/unsecured + design labels,
    #        the single source the renderers consume (plan: assurance-binary-labels) --
    security_ledger = _compute_security_ledger(records, ai_securing)

    # -- 6. Cognitive demand (deterministic) ------------------------------
    cognitive = _compute_cognitive_demand(records)

    # -- 7. Top-K selection for AI annotation ----------------------------
    top_k_records, k, covered_weight_pct = _select_top_k(records)

    # -- 8. AI annotation packaging (Component 4) -----------------------
    top_k_ids = {r["id"] for r in top_k_records}
    ai_items = _package_ai_items(top_k_records, syllabus_text, lanes, aias_result)

    # -- 8b. Bloom-Unknown content items (criterion B) -------------------
    # Emitted for all weighted assessments that remain Bloom-Unknown after
    # title+MLO grounding, carrying content_excerpt for Claude's inline read.
    bloom_content_items = _package_bloom_content_items(records, top_k_ids)

    # -- 8c. Lane-review items for ambiguous high-weight items (criterion D) --
    lane_review_items = _package_lane_review_items(records, lanes, ai_securing)

    # -- 8d. Merge all needs_ai_review items, then dedup (criterion E) ---
    all_ai_items = ai_items + bloom_content_items + lane_review_items
    all_ai_items = _dedup_ai_items(all_ai_items, records)

    # -- 9. Flags (deterministic) ----------------------------------------
    flags = _compute_flags(coverage, aias_result, lanes, records)
    # (#219) top-level alias of flags["counterfeit_assurance"] for direct access.
    counterfeit_claim = flags["counterfeit_assurance"]
    # (#219) distinct "no graded assessments" state, excluded from the batch
    # runner's certifying_on_nothing count (a truly empty course is not
    # "certifying on nothing"; it has nothing to certify).
    has_graded_assessments = len(records) > 0

    # -- 10. Durable skills (heuristic) ----------------------------------
    durable_skills = _infer_durable_skills(records)

    # -- 11. Equity and Feasibility (deterministic, per-course only) --------
    equity = _compute_equity_feasibility(records, lanes)

    # -- 12. Dimensions --------------------------------------------------
    dim_a = _build_dimension_a(top_k_records, k)
    dim_b = _build_dimension_b(records)
    dim_c = _build_dimension_c(cognitive, lanes)
    dim_d = _build_dimension_d(coverage, aias_result, lanes, records, equity)
    dim_e = _build_dimension_e(equity)

    # -- AI-Forward axis (deterministic, orthogonal to the assurance tier) ----
    ai_forward = _compute_ai_forward(records, lanes, coverage, cognitive, equity, aias_result)
    bloom_calibration_findings = _compute_bloom_calibration_findings(records)

    # -- 13. Course metadata (best-effort) --------------------------------
    course_id = str(config.get("course_id") or "")
    tier_from_config = (
        config.get("course", {}).get("tier") if isinstance(config.get("course"), dict) else None
    ) or "unknown"

    # -- 14. Assumptions record -------------------------------------------
    assumptions = {
        "K": k,
        "top_k_weight_cap_pct": covered_weight_pct,
        "model_capability": DEFAULT_MODEL_CAPABILITY,
        "modality": DEFAULT_MODALITY,
        "prompt_version": PROMPT_VERSION,
        "deterministic_vs_ai": (
            "tier, assurance_coverage_pct, lane_balance, flags, dimensions.C, "
            "and dimensions.D.lane_summary/coverage_band are deterministic. "
            "dimensions.A and needs_ai_review items are AI-annotated (within-course "
            "diagnostic only, never a cross-course ranking axis). "
            "dimensions.E (Equity and Feasibility) is fully deterministic and "
            "per-course only; it is not a cross-course ranking axis and does not "
            "affect tier or assurance_coverage_pct."
        ),
        "inventory_assumptions": inventory_assumptions,
    }

    # -- 15. Scorecard assembly ------------------------------------------
    scorecard = {
        "course": {
            "id": course_id,
            "name": config.get("course_name") or "",
            "tier": tier_from_config,
        },
        "tier": coverage["tier"],
        "assurance_coverage_pct": coverage["coverage_pct"],
        "security_ledger": security_ledger,
        "ai_policy_in_syllabus": ai_policy_in_syllabus,
        "ai_declared_assessments": ai_declared_assessments,
        "counterfeit_claim": counterfeit_claim,
        "has_graded_assessments": has_graded_assessments,
        "lane_balance": lane_balance,
        "dimensions": {
            "A": dim_a,
            "B": dim_b,
            "C": dim_c,
            "D": dim_d,
            "E": dim_e,
            "F": ai_forward,
        },
        "ai_forward_rating": ai_forward["rating"],
        "ai_forward_dimensions": ai_forward["dimensions"],
        "bloom_calibration_findings": bloom_calibration_findings,
        "objective_grounding": {
            "course_clos": course_clos,
            "module_mlos": module_mlos,
            "bloom_source_breakdown": dim_c.get("bloom_source_breakdown", {}),
            "note": (
                "CLOs read from the syllabus; MLOs from module overview pages. "
                "Assessment Bloom levels use the title verb when present and fall "
                "back to the module MLO level (fill-Unknown). When neither resolves, "
                "the assessment stays Bloom Unknown."
            ),
        },
        "flags": flags,
        "durable_skills": durable_skills,
        "needs_ai_review": all_ai_items,
        "assumptions": assumptions,
    }

    return scorecard


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    import argparse
    import json

    parser = argparse.ArgumentParser(
        description="Run the Assessment Assurance Audit scoring engine for a Canvas course."
    )
    parser.add_argument("--course-id", required=True, help="Canvas course ID")
    parser.add_argument(
        "--out",
        default="-",
        help="Output path for JSON scorecard (default: stdout)",
    )
    args = parser.parse_args()

    cfg = canvas_api.get_config(course_id=args.course_id)
    scorecard = score_course(cfg)

    output = json.dumps(scorecard, indent=2)
    if args.out == "-":
        print(output)
    else:
        with open(args.out, "w", encoding="utf-8") as fh:
            fh.write(output)
        print(f"Scorecard written to {args.out}")
