# Prototype regression cases — masking & extraction
*Plain-language record of the "type a play → it converts to codes" tests. Re-run these after any change to the masking or patient-extraction code so old bugs can't come back.*

## What "transmitted" means below
It's the text the app would actually send out — real names already swapped for codes. Products become Avatar codes (Kenzolin → PRAZ), hospitals become Location codes (Cardinal Santos → SATURN 33), roster doctors become Co-Player codes (Dr. Anna Reyes → ANIKA), and patients become initials (+ age when given), e.g. Cardo Dalisay, 45 → CD-45.

## The test battery (type this → should send this)
| # | You type | Should send |
|---|---|---|
| 1 | Gave 6 Kenzolin at Cardinal Santos | `Gave 6 PRAZ at SATURN 33` |
| 2 | 6 Kenzolin at Cardinal Santos, 2 from Mercury | `6 PRAZ at SATURN 33 2 from Mercury` (Mercury stays plain — it's an outside source, never a hospital code) |
| 3 | 6 Kenzolin and 3 Saptaz at Makati Med | `6 PRAZ and 3 CAZ at NEPTUNE 21` |
| 4 | Gave 4 Topium to Cardo Dalisay, 50urs old at St. Luke's | `Gave 4 TUK to CD-50 at JUPITER 08` (typo age still read) |
| **5** | **Gave 2 Rezpira to Juan Cruz at PHC** | **`Gave 2 ROU to JC at MARS 05`** ← see fixed bug below |
| 6 | Gave 3 Kenzolin to Juan Reyes, 60 at Makati Med | `Gave 3 PRAZ to JR 60 at NEPTUNE 21` (a patient named like a doctor stays a patient, never a Co-Player) |
| 7 | 6 Kenzolin at Cardinal Santos, referred by Dr. Anna Reyes | `6 PRAZ at SATURN 33 referred by ANIKA` |
| 8 | 6 Kenzolin at Cardinal Santos, referred by Dr. Jose Ramirez | `6 PRAZ at SATURN 33 referred by PEND-1` — unknown doctor gets a **temporary code**; the real name is withheld and logged for admin review (no longer blocked, see 2026-07-06 PM hardening) |
| **9** | **makati med, juan dela cruz 98yo, kenz 5** | **`NEPTUNE 21 JDC-98 PRAZ 5`** ← see fixed bug below (the comma-boundary leak) |
| **10** | **juan dela cruz 88yrold, kenz 5** | **`JDC-88 PRAZ 5`** ← glued age typo "88yrold" (yr+old, no space) still read as 88 |
| **11** | **these are for last month: 5 kenz at makati to juan dela cruz 60yo, just me** | **`these are for last month: 5 PRAZ at NEPTUNE 21 to JDC-60 just me`** ← "for last month" must NOT become a patient code (LM); the period stays as text so the assistant can backdate |

## Fixed bug — case 11 (time phrase masked as a patient) · fixed 2026-07-12
**What went wrong:** making previous-month reporting conversational surfaced this — a doctor writing "these are **for last month**…" had "last month" masked into a fake patient code "LM", because the patient finder treats a name after "for"/"to" as a patient and nothing stopped it on time words. That ate the period phrase before the assistant could read it, so the play wouldn't backdate.
**The fix:** time/period words — last, next, this, month(s), week(s), day(s), year(s), back, ago, yesterday/today/tomorrow, the Tagalog equivalents, and all month names/abbreviations — were added to the patient-extraction stop-list, so those phrases are never taken as a patient name. The real patient in the same sentence still converts. Trade-off: a patient literally named a month (e.g. "May") won't auto-convert — rare, and the doctor sees it and can retype. Added as automated case 11.

## Fixed bug — case 10 (glued age "88yrold") · fixed 2026-07-12
**What went wrong:** the age reader handled "88yo", "88yrs", "88 years old", and even the typo "50urs old", but not "88yrold" — where the unit and "old" are glued together with no space. The age went unrecognized, so the patient showed as "JDC 88yrold" instead of the code "JDC-88". Reported by Von 2026-07-12.
**The fix:** when the bit after the number isn't a recognized age unit on its own, the reader now strips a trailing "old"/"olds" and re-checks the remainder ("yrold" → "yr" → valid). Covers 88yrold / 88yrsold / 88yearsold. A non-age like "500mgold" is still correctly ignored. One small change in `findAgeSpan()`. Added as automated case 10.

## Fixed bug — case 9 (the comma-boundary patient leak) · fixed 2026-07-12
**What went wrong:** when a Location phrase sat right before the patient with a comma between them — e.g. "makati med, juan dela cruz 98yo" — the patient-name finder walked backward from the age and swept "med" (the tail of the hospital name) into the name. That left a comma inside the name, so the in-place swap pattern (which only allows spaces between name words) failed to match and nothing was replaced — **the real patient name "juan dela cruz" went out in the clear** and the assistant then asked the doctor to type initials. A genuine confidentiality leak (invariant #1), reported by Von 2026-07-12.
**The fix:** the backward name scan now stops at a comma/semicolon/colon (a clause boundary), so the Location clause can't be pulled into the Match. The patient converts correctly to `JDC-98`. One-line guard in `extractMatch()` (`prototype/iris_chat_prototype.html`). Added as automated case 9; all 23 checks pass.

## Fixed bug — case 5 (the "JCat" glue) · fixed 2026-07-06
**What went wrong:** when a patient had no age and was followed straight by a word (e.g. "Juan Cruz at PHC"), the initials fused to the next word — it sent `JCat` instead of `JC at`. That's a garbled code going out.
**The fix:** the code that swaps in the initials wasn't leaving a space after them. One-character change in `prototype/iris_chat_prototype.html` (the no-age patient rule) added the missing space; existing tidy-up code handles the rest. Cases 6 and 7 (comma / end-of-sentence) were checked and still work.

## Shorthand the app should understand (added 2026-07-06)
Doctors often type short forms, not full names. These must convert on-device:
| You type | Should send |
|---|---|
| gave 2 Kenz to cardinal | `gave 2 PRAZ to SATURN 33` (`Kenz`→Kenzolin, `cardinal`→Cardinal Santos) |
| 3 Saptaz at makati | `3 CAZ at NEPTUNE 21` (`makati`→Makati Medical Center) |

Full names must still work unchanged (`Cardinal Santos` → SATURN 33, `CSMC` → SATURN 33). Add new short forms as testers report them.

## Edit button on the confirm card (added 2026-07-06)
Tapping **Edit** must clearly respond — it should post a short "what would you like to change?" prompt and put the cursor in the type box. (Before the fix it silently focused the box and looked broken.) You correct the play in plain words, e.g. "make it 4, not 6".

## Game-vocabulary word substitution (added 2026-07-09, tester VON-03)
Real-world category words must render as game vocabulary — on the doctor's own message bubble AND in the text sent to the AI (invariant #6). Deterministic, on-device, typo-tolerant. Product/Location/Co-Player CODES are never touched.
| You type | Should show/send |
|---|---|
| who are the doctors in this hospital | `who are the Players in this Location` |
| list the hospitals and doctors | `list the Locations and Players` |
| who are the doctrs here (typo) | `who are the Players here` |
| show me the hospitl roster (typo) | `show me the Location roster` |
| gave 6 PRAZ at SATURN 33 | `gave 6 PRAZ at SATURN 33` (codes untouched) |

Word families: hospital/hospitals/clinic/clinics → Location(s); doctor/doctors/physician(s)/doc(s)/md(s) → Player(s). Extend as testers report more. **Known nuance:** a singular "doctor" immediately followed by another word (e.g. "doctor works…") is treated by the masking engine as possibly *naming a specific Co-Player* and is hidden as a temporary code (PEND-#) — deliberate, so a real name can't slip. The category-word conversion covers the plural and standalone forms.

## Off-assignment Location flag (added 2026-07-09, tester VON-04)
Each Player has assigned/practicing Locations (roster data, server-side per doctor). A play at a Location **not** in that set still posts, but a deterministic on-device check flags it for admin validation — codes only (Player + Location). Never the AI.
| Play Location | Player assigned to | Flagged for admin? |
|---|---|---|
| NEPTUNE 21 | SATURN 33, JUPITER 08 | **Yes** — validate: add Location to Player, or deny play |
| SATURN 33 | SATURN 33, JUPITER 08 | No (assigned) |
| LOC-PEND-1 | SATURN 33, JUPITER 08 | No (temporary Location — already in the review queue) |
| NEPTUNE 21 | (none on file) | No (no assignment to check against) |

The play is never blocked — admin adds the Location to the Player or denies the play after validating, and the record is adjusted per their disposition. (`offAssignment()` in the prototype.) **Admin side (added 2026-07-09):** an **Assignments** tab manages each doctor's Locations (add / remove), and each flag in the Review queue has **Add Location to Player** / **Deny play** buttons — verified live. (Admin CRUD is UI logic, exercised by hand; the deterministic `offAssignment()` rule is what the automated checks cover.)

## Multi-play (bundled plays in one message) — added 2026-07-13
A doctor can bundle several plays in one message and get a single review-all card (DECISIONS #35). This is a **conversation-level** behavior (not just masking), so it's covered by the new end-to-end replay suite rather than the masking-only checks.
- **Sample play used:** "Logging for July, just me on all of these. 6 Kenzolin at Cardinal Santos to Maria Santos 52. 3 Saptaz at Makati Med to Juan Cruz 60. 2 Rezpira at PHC to Ana Lim 45." → answer "all from inside".
- **Should produce:** ONE `confirm_batch` with 3 plays, each masked (SATURN 33/MS-52, NEPTUNE 21/JC-60, MARS 05/AL-45), all SINGLE (from "just me"); the review card lets the doctor submit all three at once. No crash, no raw JSON, no real name (invariants #1/#6).
- **On-device guards (checked in the browser):** an incomplete play in the batch shows "⚠ needs a fix" and blocks Submit-all; two identical plays in one batch flag "⚠ possible repeat" and block until resolved.

## Banned-word output filter (invariant #6) — added 2026-07-13, tester VON-09
Real-world program words must never appear in an assistant message on screen (not just hospital/doctor). When asked "who developed you", the assistant replied "usage-reporting assistant" — banned words leaked. Fix: `gamifyOut()` runs the category-word swap (hospital→Location, doctor→Player) AND a wider banned-word net (usage→activity, report→play, patient→Match, medicine/drug/product→Avatar, vial→unit, etc.) over the AI's displayed text. Applied ONLY to assistant output, not the doctor's typed input (so their words like "report" still trigger the report action). "can" (English modal) and "referred" (co-player wording) are deliberately left alone. The prompt self-description was also reworded so the model doesn't produce the banned words in the first place. Covered by the e2e samples `offtopic-who-made-you` / `offtopic-capabilities` and a banned-word assertion on every scenario.

## Era-filtered admin report (backdated plays) — added 2026-07-13, tester VON-10
A backdated (previous-month) play was appearing in the current Era's admin report. Fix: the admin report is Era-aware (`playsForEra()`), a play counts toward the Era it was filed for, and the Reports tab has an Era selector; Dashboards + Reconcile are scoped to the open Era. Verified live in the admin console (a June play files under Era 2606, not 2607). Admin-side UI — verified by hand, not yet in the automated suite.

## Patient initials + hospital abbreviations + "attending is me" — added 2026-07-21, tester VON-15 (DECISIONS #38)
From a live test of a real report format: "For reporting po: JU 60/F UST Hospital Saptaz 2.25g 42 vials Attending: Dr. Daisy Tagarda" (the reporting doctor named herself; single play). Six automated masking cases guard the fixes (suite now 31/31):
- **Patient initials kept:** `gave 3 saptaz to JU 60/F` → `gave 3 CAZ to JU-60` (a short all-caps abbreviation is kept whole, not trimmed to J-60).
- **Hospital abbreviation → Location:** `gave 2 saptaz at usth` → `gave 2 CAZ at PLUTO 12`; `2 saptaz at ust` → `2 CAZ at PLUTO 12` (alternate names the admin lists on the hospital are recognized).
- **Self as attending → single play:** signed in as Dr. Daisy Tagarda, `2 saptaz at ust attending Dr. Daisy Tagarda` → `2 CAZ at PLUTO 12 attending me`; also by surname + honorific `... attending dra tagarda` → `... attending me` (no Co-Player code).
- **A real co-player under "Dra" is NOT the user (leak guard):** signed in as Dr. Daisy Tagarda, `3 kenzolin at makati med with dra jenny lim` → the real name is withheld and a temporary Co-Player code is raised (the "Dra" honorific is now caught; it used to be missed).

## Doctor alternate names + self-growing name library — added 2026-07-21, tester VON-16 (DECISIONS #39)
Doctors get admin-managed alternate names (nicknames/initials/abbreviations), recognized like hospital abbreviations; unknown names captured in the review queue can be linked to an existing doctor/hospital in a couple of clicks, and the link is shared across devices via the backend. Four automated masking cases guard the core behavior (suite now 35/35):
- **Nickname/initials recognized:** signed in as another doctor, `gave 3 kenzolin at makati med with kdc` → `... with THOR`; `... with doc ken` → `... with THOR` (admin-added alternates of Dr. Ken Dela Cruz).
- **Signed-in doctor excluded:** even with Daisy Tagarda on the Doctors roster, signed in as her, `... attending Dr. Daisy Tagarda` → `... attending me` (her own name is not matched as a co-player).
- **Same doctor named by someone else is a real Co-Player:** signed in as another doctor, `... with Dr. Daisy Tagarda` → `... with DAWN`.
- **Cross-tab growth loop (browser-verified):** doctor names an unknown "Dr. Kenzo" → PEND-1 in the review queue → admin picks Dr. Ken Dela Cruz from the dropdown and clicks "Add as alternate name" → the doctor signs in again → "with kenzo" is now recognized as THOR. The link persists to the backend (`roster_links`) and is applied on sign-in/refresh.

## Quick-access Location chips + Location code recognized as itself — added 2026-07-21, tester VON-17 (DECISIONS #40)
The doctor composer shows the Player's assigned Locations as one-tap chips (ranked by usage); tapping one drops `at <CODE> ` into the composer. For that to work, a Location CODE must be recognized as itself by the masker (otherwise `at PLUTO 12` reads as an unknown hospital → LOC-PEND). One automated masking case guards this (suite now 36/36):
- **Code recognized as itself:** `at MARS 05 6 kenzolin to Maria Santos 45yo` → `at MARS 05 6 PRAZ to MS-45` (the Location code passes through as that Location; the patient still converts; no real name leaks).
- **Chip ordering (browser-verified):** DAWN's chips start in assigned order (PLUTO 12, NEPTUNE 21); after NEPTUNE 21 is reported more often, it moves to the front. Tapping PLUTO 12 pre-fills "at PLUTO 12 " and the doctor adds the rest.

## Surname-first given-name leak → gated — added 2026-07-22 (found by the QA masking-holds fuzz)
When a patient is written surname-first with a comma (`DELA CRUZ, Roberto, 40`), the age/preposition nets capture the surname (`DC`) and stop at the comma, leaving the given name `Roberto` in the clear — neither masked nor gated. Pre-existing (identical in the prototype and the shipping app; not a deploy regression). Fix: the net-3 gate (`detectPossibleNames`) now also flags a name-like word sitting right after an already-extracted patient placeholder (`⟦PX:…⟧`), so the given name is verified with the doctor (blocked → replaced) before anything is sent. Never mis-masks; only asks. Guarded by `tests/masking_holds.mjs` (§C surname-first).
- **Given name after extracted patient is gated:** `gave 6 kenzolin to DELA CRUZ, Roberto, 40 at cardinal` and `to DELA CRUZ, Roberto 40` → the gate flags `Roberto`; on "Patient" it becomes initials, so no real name reaches the AI.
- **Mixed-case surname-first already safe:** `for Santos, Maria, 33` → both `Santos` (after preposition) and `Maria` (seed name) are gated.
- **Normal plays do NOT newly gate:** `6 kenzolin to Maria Santos 45yo`, `to MS-45 at cardinal`, etc. — no new gate prompt (the patient is extracted; nothing flagged).
- ~~**KNOWN GAP (tracked, not yet fixed):** an all-caps single surname after a preposition (`for SANTOS, Maria`) is still read as a code by the gate and can leak.~~ **RESOLVED 2026-07-23 — see next section.**

## Surname-first ALL-CAPS leak → fully closed — added 2026-07-23 (DECISIONS #55, blocker #4 closed)
The residual all-caps surname-first leak is now fixed on-device. The gate used to skip **every** ALL-CAPS token as a code, so a real surname like `SANTOS` was never masked or asked. Two-part fix (the age/preposition nets are unchanged — this only adds surname-first handling around them): **(1) auto-convert** — `extractSurnameFirst` recognizes "SURNAME, Given, age" both preposition-led and no-preposition (disambiguated by the second comma) and folds both names into one Match code, guarded by `nameLikeWord` so Location/product/number clauses are never swallowed; **(2) ask backstop** — the gate now skips only a *genuine* code (`isCodeLikeToken`), and flags an unmasked name-like word sitting just before a `⟦PX⟧` placeholder (`beforePatient`). Guarded by `tests/masking_holds.mjs` §C (now asserts the SURNAME itself is handled) — suite grew 106 → 109; name-hiding stays 46/46.
- **All-caps surname-first, preposition-led:** `2 prosec for SANTOS, Maria, 33` → `2 OTEM for SM-33` (surname masked, not leaked). Verified live.
- **All-caps two-word surname:** `to DELA CRUZ, Roberto 40` → `to DCR-40`.
- **No preposition, at a clause start:** `SANTOS, Maria, 52 got 6 prosec` → `SM-52 got 6 OTEM`.
- **No false gating:** real codes (`MS 52`), medical shorthand (`IV`, `MG`, `STAT`), and ordinary words after the patient (`got`, `had`) are not questioned; normal plays (`to Maria Santos, 52`) mask exactly as before with no new prompt.

## How to re-run these — the FULL QA suite (run on every change), automated
Run `node tests/qa.mjs` from the project root. It runs the whole battery and reports each as PASS/FAIL/SKIP: masking-holds fuzz, the 46-case name-hiding regression, no-report-lost (offline/restart/flicker), end-to-end (doctor→admin, codes-only), stress (volume + malformed), and speed (masking timing + AI round-trip). The live end-to-end (`e2e_plays.mjs`) runs only when the session harness is up, otherwise it is skipped (never failed). The suite reads the real engine and the real offline queue straight out of the shipping app (`mobile/www/index.html`) each run, so it always checks what ships. It also runs automatically whenever app code or a test is edited (via a project hook) — a regression surfaces immediately.

## How to re-run these — full pipeline (masking + live assistant), automated
Run `node tests/e2e_plays.mjs` with the session harness running. It replays the saved sample plays in `tests/sample_plays.mjs` — the actual messages a tester would type — through the whole app (on-device masking AND the live assistant) and checks each function: play understood, codes correct, confirm/confirm_batch produced, period/co-player handled, and no real name ever sent. Add a new row to `tests/sample_plays.mjs` whenever we add a behavior worth re-checking (keep the play's real names in `realNames` so the leak guard covers it). Run a subset with e.g. `node tests/e2e_plays.mjs multi-play-batch`.

## How to re-run these — masking only (no assistant, fast), automated
Run `node tests/masking_eval.mjs` from the project root. It reads the real masking engine straight out of the prototype and replays every case above, checking the codes come out right and — on every case — that no real name appears in the sent text. Green = safe to proceed; red = a fixed bug came back. This also runs automatically whenever the prototype's masking code is edited (via a project hook), so a regression surfaces immediately.

## How to re-run these — by hand
Open the prototype in a browser and, for each row, type the phrase and press send — check the grey bubble that appears shows the "should send" codes with no real names and no glued-together words. (Inside Claude.ai the assistant will also reply; in a plain browser only the conversion half runs, which is exactly what these cases check.)
