# Plan: Equity & Feasibility lens for `assurance-audit`

Status: spec (pre-implementation)
Owner: ASUCB
Coder: Sonnet 4.6 sub-agent · Reviewer: Opus (max effort), independent of coder
Source of need: reconciliation of Wayne's v2 "AI-Forward Assessment Rubric" against `assurance-audit` (see chat 2026-06-19). Wayne's rubric scores **Equity, workload, and 7.5-week feasibility** as a first-class dimension; `assurance-audit` currently captures it only as the binary `over_surveillance` flag.

## Problem

`assurance_audit.score_course()` can recommend "secure" moves (proctoring, oral defense, process portfolios) **without scoring whether that move is feasible or equitable** in a fully-online accelerated (7.5-week) ASU course. Its only equity signal is `flags.over_surveillance` (fires when Lane 1 weight > 80%) — a single bool that:
- does not say *which* assessments create the burden or *how much*,
- does not distinguish surveillance burden (proctoring) from synchronous-scheduling burden (live orals) from instructor-labor burden (many hand-graded anchors) from compressed-term high-stakes concentration,
- never reaches the recommendation text, so the skill can advise an inequitable fix.

## Goal

Promote equity/feasibility from a binary flag to a **deterministic, reported dimension (`dimensions.E`)** that (1) names the specific feasibility burdens with grade weights, and (2) **modulates Dimension D recommendations** so a "secure" recommendation prefers the lowest-burden mechanism that still certifies learning.

## Non-goals / invariants (do NOT change)

1. **`tier` and `assurance_coverage_pct` stay exactly as they are.** Equity is orthogonal to assurance: an over-proctored course is still `Assured` (it *does* certify learning) but should be flagged high-burden. Tier remains the deterministic cross-course sort key.
2. **Batch ranking is untouched.** `assurance_batch.aggregate_scorecards` ranks on `tier` + `assurance_coverage_pct` only (see its docstring contract). Dimension E is **per-course report only**, never a ranking input, never a cohort-sort axis. Do not add E to `_sort_key`.
3. **No new Canvas API calls / no throughput budget change.** E is computed purely from fields already on each inventory record. Do not fetch anything new in `assurance_inventory.py`.
4. **No LLM call in this module.** E is fully deterministic. It does not add `needs_ai_review` items.
5. House rule (this file): **no em/en dashes in string literals** — use colons/semicolons/periods. (Enforced by existing convention; CI is py3.11 — also avoid backslashes inside f-string expressions, a known CI-vs-local trap.)

## Design

### Inputs (all already present on each record)
`grade_weight_pct`, `proctoring_signal` (bool), `title` (for oral keywords via existing `_ORAL_KEYWORDS`), `submission_types`, `engine`/`canvas_type` (to tell auto-graded objective quizzes from hand-graded work), plus the `lanes` map already computed in `score_course`. (`has_rubric`/`peer_reviews` exist on the record but are not consumed by E.)

> **Reviewer fix (BLOCKER #1):** `flags.over_surveillance` is NOT a proctoring measure. It is `lane1_weight > 80` (`assurance_audit.py:586-591`), where `lane1` = proctored **OR** oral **OR** process-trail **OR** peer-reviewed (`_classify_lane`, lines 204-209). So a course that is 90% oral defenses + portfolios with zero proctoring still fires `over_surveillance=True`. That legacy flag is conceptually broad ("surveillance" is a misnomer for it) and **stays exactly as-is for back-compat**. Dimension E introduces a *new, correctly-scoped* `surveillance_weight_pct` that counts **proctoring only**. The two are different quantities by design; E does not reuse `OVER_SURVEILLANCE_THRESHOLD` and makes no claim that they agree.

### New deterministic function

```
_compute_equity_feasibility(records, lanes) -> dict
```

Returns (all weights are grade_weight_pct, 0-100, rounded 2dp):

```
{
  "feasibility_band": "low_burden" | "moderate_burden" | "high_burden",
  "surveillance_weight_pct": float,     # sum weight of proctoring_signal=True items (proctoring ONLY)
  "synchronous_anchor_count": int,      # Lane 1 items whose title hits _ORAL_KEYWORDS
  "synchronous_weight_pct": float,
  "handgraded_anchor_count": int,       # informational; see note below (does NOT drive a burden)
  "high_stakes_concentration_pct": float,  # sum weight of items each >= HIGH_WEIGHT_THRESHOLD
  "burdens": list[{type, detail, items:[{id,title,grade_weight_pct}]}],
  "label": "deterministic within-course feasibility diagnostic; not a cross-course ranking axis",
}
```

**Three** burden types drive the band (ponytail cut, reviewer #7: the `instructor_labor` burden is dropped — it is a coarse proxy with no enrollment data and never changed a recommendation). Each burden cites assessments + weights:
- `type="surveillance"` when `surveillance_weight_pct >= SURVEILLANCE_HIGH_BURDEN_PCT` (new constant, default 50.0): proctoring carries equity cost (bandwidth, private space, disability accommodation, test anxiety — Marano / Duncan & Joyner / Felsinger in Wayne's evidence base).
- `type="synchronous"` when `synchronous_anchor_count >= SYNC_ANCHOR_COUNT` (default 2): live orals require scheduling against an async, time-impoverished cohort; recommend sampling.
- `type="high_stakes_concentration"` when `high_stakes_concentration_pct >= CONCENTRATION_PCT` (default 70.0): weight concentrated in few high-stakes items is fragile in a 7.5-week term; distributed low-stakes practice is the principle (Hattie formative d=0.90; online-JD "substitute time, not add").

`handgraded_anchor_count` is **reported as an informational field only** (the deterministic count of Lane 1 anchors that are not auto-graded objective quizzes). It is NOT a burden and does NOT affect the band, because without enrollment data we cannot honestly assert a labor burden. Enrollment-aware labor scoring stays out of scope.

**Hand-graded predicate (reviewer #5, reuse the file's existing idiom):** a Lane 1 anchor is hand-graded when it is NOT a quiz, i.e. `lane == "lane1" and not _is_quiz(rec)` where `_is_quiz(rec)` is the exact test already written three times in this module:
`rec.get("canvas_type") == "quiz" or rec.get("engine") in ("classic_quiz", "new_quizzes", "new_quizzes_opaque")`. A proctored classic quiz (`proctoring_signal=True`, `engine="classic_quiz"`) is therefore excluded; a graded discussion (`engine="graded_discussion"`) is NOT a quiz and counts. Factor the predicate into a small `_is_quiz(rec)` helper rather than re-inlining it a fourth time.

### Band logic (deterministic)
- `high_burden` if `len(burdens) >= 2`.
- `moderate_burden` if `len(burdens) == 1`.
- `low_burden` if `len(burdens) == 0`.

Do **not** reference `OVER_SURVEILLANCE_THRESHOLD` here. Add a distinctly named constant `SURVEILLANCE_HIGH_BURDEN_PCT = 50.0` for the surveillance burden trigger so no reader conflates it with the legacy lane1-weight flag (reviewer #1, #8).

### New dimension builder
`_build_dimension_e(equity) -> dict` wraps the computed dict with a `description` and the `label`. Mirror the shape/voice of `_build_dimension_c` (deterministic, no AI note).

### Recommendation modulation (the behavioral payoff)
Two internal signatures change (both private, no public API impact — reviewer #3):
- `_build_dimension_d(coverage, aias_result, lanes, records)` → `_build_dimension_d(coverage, aias_result, lanes, records, equity)`; update its sole call site at `assurance_audit.py:912`.
- `_build_recommendations(coverage, aias_result)` → `_build_recommendations(coverage, aias_result, equity)`; its sole caller is `_build_dimension_d` (line 734). No other callers exist (grep-confirmed) and no test imports either function directly.

Behavior:

- When emitting a **Secure** rec (UNASSURED / PARTIALLY_ASSURED tiers) and `equity["surveillance_weight_pct"]` already high (>= `SURVEILLANCE_HIGH_BURDEN_PCT`): phrase the secure move toward **lower-burden mechanisms** — "add a process-trail or recorded asynchronous defense rather than additional proctoring, given current surveillance burden of {pct}%."
- When `synchronous_anchor_count >= SYNC_ANCHOR_COUNT`: append "sample oral/live components rather than requiring them of all students" to the secure rec.
- Add a standalone rec when `feasibility_band == "high_burden"`: name the dominant burden and the lowest-burden alternative. Vocabulary stays **secure / re-lane / declare / liberate** + plain feasibility language. No surveillance-positive framing.

Keep `flags.over_surveillance` exactly as computed today (back-compat: `test_assurance_batch` and the golden flag set reference it). Optionally surface `feasibility_band` is enough; do **not** add new flag keys unless a test needs one (prefer the dimension over flag-set growth).

### Wiring in `score_course`
After step 6 (cognitive), add: `equity = _compute_equity_feasibility(records, lanes)`. Build `dim_e = _build_dimension_e(equity)`. Pass `equity` into `_build_dimension_d` → `_build_recommendations`. Add `"E": dim_e` to `scorecard["dimensions"]`. Append one line to `assumptions["deterministic_vs_ai"]` noting E is deterministic and per-course only.

## Schema / contract impact

- `dimensions` gains key `"E"`. **Update the module docstring schema block** (lines ~25-30) and `assumptions.deterministic_vs_ai` text.
- **No existing test breaks from adding `dimensions.E`** (reviewer #2): `TestScorecardSchema.test_dimensions_keys` (lines 803-806) is a **subset** check (`for key in REQUIRED_DIMENSIONS: assert key in sc["dimensions"]`), not an equality check. Still, update `REQUIRED_DIMENSIONS` to include `"E"` so the contract asserts E's presence going forward. This is a contract tightening, not a breakage fix.
- Recommendation/dimension signature changes are internal (leading underscore) — no public API change. `score_course` signature unchanged.

## Test plan (acceptance criteria)

All in `tests/test_assurance_audit.py`, new class `TestEquityFeasibility`, plus the golden-schema update. Tests must pass under the repo `.venv` with `pytest tests/test_assurance_audit.py tests/test_assurance_batch.py -q`.

1. `dimensions.E` present with all documented keys; `feasibility_band` in the 3-value enum. (golden)
2. Surveillance burden: 2 proctored items (`proctoring_signal=True`) summing >=50% → `surveillance` burden present and `surveillance_weight_pct` is proctoring-only. Add a control case: 90% oral-defense weight + 0% proctoring → `surveillance_weight_pct == 0` and NO surveillance burden (proves E does not inherit the lane1-weight definition of the legacy flag).
3. Synchronous burden: 2+ oral-titled Lane 1 anchors → `synchronous_anchor_count >= 2`, `synchronous` burden present.
4. Hand-graded count is informational and excludes auto-graded quizzes: a proctored classic-quiz anchor does NOT increment `handgraded_anchor_count`; a graded discussion anchor DOES. `handgraded_anchor_count` never appears in `burdens` and never changes `feasibility_band`.
5. Concentration: weight >=70% in items each >=20% → `high_stakes_concentration` burden present.
6. Clean course (one 40% process-portfolio anchor + distributed low-stakes, no proctoring) → `low_burden`, `burdens == []`.
6a. Two burdens (e.g. surveillance + concentration) → `high_burden`. Exactly one burden → `moderate_burden`.
7. **Invariance:** for every fixture above, `scorecard["tier"]` and `scorecard["assurance_coverage_pct"]` equal the values produced before E existed (snapshot a couple of representative cases). Proves E does not perturb the deterministic tier.
8. **Recommendation modulation:** UNASSURED course with high surveillance burden → a recommendation string contains the lower-burden phrasing and does NOT recommend "additional proctoring." (substring assertions; keep them robust to wording, assert on "process-trail"/"asynchronous"/"sample", not full sentences.)
9. **Batch unchanged:** `test_assurance_batch` still green; ranking ignores E (no code change there — assert by running the suite).
10. No em/en dash in any new string literal (grep gate in the PR description; reviewer verifies).

## SKILL.md changes

- Step 2 scorecard field list: add `dimensions.E` (Equity and Feasibility, deterministic).
- Step 4 Report: add section "8. Dimension E: Equity and Feasibility" (band + named burdens with weights + the lower-burden alternative). Renumber Assumptions to 9 if needed, or insert E before Durable Skills — keep Tier/Coverage first (unchanged).
- Note explicitly: E never changes the tier and is not cross-course comparable.

## Risks / mitigations

- **Thresholds are judgment calls.** Ship them as named module constants (`SURVEILLANCE_HIGH_BURDEN_PCT`, `SYNC_ANCHOR_COUNT`, `CONCENTRATION_PCT`) so they are tunable without touching logic. Defaults above are starting points; flag in PR for review.
- **Surveillance vs legacy flag confusion.** E's `surveillance_weight_pct` is proctoring-only; `flags.over_surveillance` is lane1-weight. They are intentionally different. The constant name `SURVEILLANCE_HIGH_BURDEN_PCT` (distinct from `OVER_SURVEILLANCE_THRESHOLD`) and a one-line comment at each definition keep them from being conflated.
- **Instructor labor is unscored on purpose.** `handgraded_anchor_count` is reported but drives nothing, because enrollment is not on the record and a labor claim would overreach. Stated as informational in the dimension `description`.
- **Synchronous overlaps hand-graded.** A "Portfolio Defense" hits `_ORAL_KEYWORDS` (counts toward `synchronous_anchor_count`) and is also hand-graded (counts toward `handgraded_anchor_count`). The overlap is intentional and harmless: only `synchronous_anchor_count` drives a burden.

## Out of scope

- Enrollment-aware labor scoring (needs an extra API call; defer).
- Any change to `tier`, coverage bands, batch ranking, or `needs_ai_review`.
- Leadership PDF/deck (separate phase, per SKILL.md Step 5 note).
- Editing Wayne's workbook (the dropdown defect is a separate, declined item).
