# Fact-check pass — loopers/ baseline data
Date: 2026-05-12

Twelve checks against the data feeding `loopers/public/data/loopers.json`. Three confirmed bugs, several "honest but worth labeling" caveats, and one full pass.

## ✅ Passes

| # | Check | Result |
|---|---|---|
| 1 | All 59 Looper IDs have a corresponding `sfoil/details/{id}.json` | **0 missing** |
| 3 | `in_custody_now` matches "most-recent booking has null `released_date`" | **0 disagreements** |
| 4 | `n_bookings` field matches actual booking-array length | **0 disagreements** |
| 5 | Hero stats (n=59, in=30, out=29) | **match exactly** |
| 8 | `age` populated for every Looper, range 23-60, median 37 | **0 missing, no outliers** |
| 8b | No future-dated bookings (sanity check on `datecharged`) | **0** |
| 11 | Sparkline total = sum-of-in-window-bookings (101) | **match** |

## 🟥 Bug 1 — homeless-tag set in build-data.ts is **incomplete** vs the original Looper definition

The app's `homeless_tag_hits` is computed by `loopers/scripts/build-data.ts` from a regex set of 10 tags. The **original** Looper-cohort selection (`data/_loopers.json`, produced earlier) used a different/richer set.

Evidence:

```
Reproduced cohort (≥2 bookings + ≥3 of my tag set): 53 people
Shipped _loopers.json cohort:                       59 people
In reproduced but NOT in shipped: 4
In shipped but NOT in reproduced: 10
n_tags disagreements (recomputed vs shipped): 20/59
```

Specific gaps in my regex set:

| original tag | my regex catches it? |
|---|---|
| PARAPHERNALIA (HS 11364) | ✅ |
| LOITERING-DRUG (HS 11532) | ❌ "LOITERING TO COMMIT CONTROLLED SUBSTANCE OFFENSES" |
| CONTROLLED-SUBSTANCE-POSSESSION (HS 11350/11377…) | ✅ |
| UNDER-INFLUENCE (HS 11550) | ❌ "UNDER THE INFLUENCE OF A CONTROLLED SUBSTANCE" |
| TRESPASS (PC 602) | ✅ |
| PUBLIC-INTOX (PC 647(f)) | ✅ |
| ILLEGAL LODGING (PC 647(e)) | ✅ |
| INTERFERING WITH BUSINESS (PC 602.1) | ❌ "INTERFERING WITH A BUSINESS" |
| COMMERCIAL-BURG-2 (PC 459) | ✅ |
| SHOPLIFTING (PC 490.5 / 459.5) | ✅ partial |
| PUBLIC NUISANCE (PC 372/370) | ✅ |

**Impact**: app shows `n_tags` chips that don't match the upstream selection criterion the cohort was built against. E.g. `DELAO CARLOS` shows 3 tags in the UI but was selected based on 5 in the source.

**Concrete example** — `MARSHALL DENNIS` (shipped n_tags=3) has charges including "LOITERING TO COMMIT CONTROLLED SUBSTANCE OFFENSES" which the app's regex misses entirely.

## 🟥 Bug 2 — UI shows `n_tags` from the source, but `homeless_tag_hits` from build-data — these can disagree

The board UI displays `L.n_tags` (the column header "Tags") which comes from upstream. The detail expansion shows `homeless_tag_hits` chips from my regex set. With the incomplete tag set in Bug 1, the chip count and the column count tell different stories for 20/59 rows.

**Fix**: extend the regex set to match the upstream definition, then drop `homeless_tag_hits` in favor of `n_tags` (or re-derive `n_tags` here and stop carrying the field forward from `_loopers.json`).

## 🟧 Caveat 1 — only **30/59 Loopers visibly cycle within the 138-day window**

The Looper definition says "≥2 bookings in a 12-month window." Of the 59 shipped, only 30 have a complete booked→released→booked sequence **inside** sfoil's Dec-25-to-May-11 scrape window. The remaining 29 had their qualifying second booking before sfoil started polling.

The ribbon visualization only shows within-window stints. For ~half the cohort, the "loop" isn't visible in the UI even though the qualifying multi-booking pattern is real (just partially pre-window).

**Fix options:**
- (A) Add a "pre-window" tick marker at the left edge of the ribbon when bookings exist before `window_start`.
- (B) Add a small "1 prior booking (pre-window)" annotation below the ribbon for those rows.
- (C) Leave as-is and document.

## 🟧 Caveat 2 — 30/131 bookings (23%) fall before `window_start`

These are visible in the row-expansion detail cards but missing from the ribbon and the sparkline. The sparkline math is consistent (101 in-window bookings, 101 sparkline counts), but the spec ambiguity — "is this a 138-day window or a complete-history view?" — is worth declaring on the page.

**Fix**: small footer note: "Ribbon shows only the 138-day SF Sheriff scrape window; older bookings are visible inside each row."

## 🟧 Caveat 3 — sfoil source has **literal-duplicate charges** within a single booking

71% of bookings (93/131) have at least one charge literal that appears 2+ times in the same booking. This is **correct upstream behavior** — SFSD records each contraband count as a separate `bookingchargeid` row. The preview already deduplicates this (top-3 distinct), but the detail expansion shows every count.

**Fix options**:
- (A) Dedupe on `(statute, literal)` in the detail expansion too — cleaner but loses count info.
- (B) Collapse to `POSSESSION OF UNLAWFUL DRUG PARAPHERNALIA ×3`.
- (C) Leave as-is — the duplication signals "3 baggies vs 1 baggie", which is meaningful information about the actual stop.

## ✅ Pass — no Loopers cross-match Path 1 court-confirmed decedents

Token-overlap check between the 59 Looper names and the 42 Path-1/Path-1b decedent names: **zero matches**. Consistent with the earlier finding that the court-abatement cohort (mostly single-arrest defendants who died mid-case) is structurally *different* from the cycling Looper cohort (whose cases never reach plea before re-arrest).

## ✅ Pass — controlled-substance hits are 91% user-possession, not sale

148 controlled-substance charges across the Loopers: 134 user-side (possession/under-influence/paraphernalia), 14 sale/transport. The 9% sale rate is consistent with the cohort being end-users, not dealers.

## Summary

| severity | finding | action |
|---|---|---|
| 🟥 BUG | build-data.ts tag set incomplete (missing LOITERING-DRUG, UNDER-INFLUENCE, INTERFERING-W-BUSINESS variants) | extend regex set, re-derive n_tags |
| 🟥 BUG | UI's `n_tags` column and detail chips disagree by ≤2 for 20 Loopers | unify by re-deriving from same regex |
| 🟧 CAVEAT | 29/59 Loopers don't visibly cycle in the 138-day window | add pre-window marker + footnote |
| 🟧 CAVEAT | 30 pre-window bookings hidden from ribbon/spark | footnote |
| 🟧 CAVEAT | Sfoil records duplicate charge counts per booking | accept or collapse, document choice |

## Fixes applied (same commit)

1. **Regex extended** to match `reports/12_loopers.md` tag set 1-to-1 (added LOITERING_DRUG, UNDER_INFLUENCE, INTERFERING_BUSINESS, PUBLIC_NUISANCE, VANDALISM; tightened CONTROLLED_SUBSTANCE to "POSSESSION OF [A] CONTROLLED SUBSTANCE" to exclude sale/transport hits).
2. **`n_tags` re-derived** from regex inside build-data. UI column and detail chips now share one source. Upstream value retained as `n_tags_upstream` for traceability.
3. **Ribbon pre-window marker**: rows with bookings before `window_start` get a small `+N` chip at the left edge of the ribbon.
4. **Footer note** added to explain the ribbon scope.

### Reconciliation after fix:

```
all 59 Loopers above 3-tag floor:    yes (was: 1 below)
exact agreement with upstream:       35/59 (59%)
within ±1:                           55/59 (93%)
within ±2:                           59/59 (100%)
mean |delta|:                        0.47 tags
```

The remaining 24 disagreements are within ±1-2 tags and reflect tag-set decisions that may have differed in the upstream selection script (which isn't in scope here). Cohort membership is preserved — every Looper still qualifies under the published definition with the derived count.

### What I deliberately did NOT change

- **Kept duplicate charge counts** in detail-expansion cards (Caveat 3). Three `POSSESSION OF UNLAWFUL DRUG PARAPHERNALIA` rows in one booking means three contraband items found in one stop — that's informational, not noise.
- **Did not re-run the upstream Looper-selection** against the current sfoil snapshot. The cohort is what we shipped earlier; doing it again would conflate "the data changed" with "the criterion changed."
