# What counts as an AI law

The site's headline number is "AI laws enacted", not "bills that matched an AI keyword". A bill
reaches that number only if it is **enacted**, is **not a resolution**, and a model reading the
rubric below classified it `regulates`. Joël's overrides beat the model every time.

The text between the two `RUBRIC` markers is read out of this file by
`scripts/classify-ai-laws.mjs` and sent verbatim as the system prompt. Editing this file changes
what the classifier is asked. Nothing else is sent as instructions.

<!-- RUBRIC:BEGIN -->
A bill *regulates AI* if its primary purpose is to regulate, restrict, prohibit, license, mandate disclosure, transparency, audit, testing, or impact assessment of, create liability or penalties for, or establish governance, oversight, procurement rules, a task force, or a study body for: artificial intelligence, automated decision systems, algorithmic tools, generative AI or chatbots, deepfakes and synthetic media, or facial recognition and biometric identification. Sector-specific rules count (health, insurance, employment, elections, education, government use, criminal law). A bill does *not* qualify if AI appears only in an appropriations line, a commendation or memorial, a curriculum requirement, a general technology definition, or an unrelated omnibus. Output one of `regulates | mentions | unrelated`, and for `regulates` one subtype from `restriction | disclosure | liability | governance | sector-rule | deepfake | biometric | study-body`, plus a one-sentence reason quoting the title.

Use `mentions` when AI appears in the bill but regulating it is not the bill's primary purpose. Use `unrelated` when nothing in what you are shown is about AI at all. For `mentions` and `unrelated`, set the subtype to `none`. Judge only the title, summary, subjects and latest action you are given; do not assume text you cannot see.
<!-- RUBRIC:END -->

## How it runs

| | |
| --- | --- |
| Script | `scripts/classify-ai-laws.mjs` (`npm run classify`) — never imported by `src/` |
| Model | `claude-opus-5`, `output_config.effort: "low"`, `max_tokens: 1024` |
| Sampling | none sent. `temperature` and `top_p` are rejected by this model; determinism comes from a frozen rubric plus committed labels |
| Output | structured outputs (`output_config.format` from a zod schema); `parsed_output === null` is a failed classification, not a guess |
| Input per bill | state, bill number, session, title, 600-char summary, `subjects`, latest action text |
| Caching | the rubric is the only `system` block and carries `cache_control: {type:"ephemeral"}`; the bill is the whole user turn |
| Scope | every bill in the checkpoint whose recomputed `statusBucket` is `enacted` (356 today). Bills already in the labels file are skipped unless `--force` |
| Refusals | `stop_reason === "refusal"` leaves the bill unlabelled and puts it in the review list |

## The two data files

`data/ai-law-labels.json` — what the model said, keyed by OpenStates bill id:

```jsonc
{
  "ocd-bill/6c6a…": {
    "label": "regulates",          // regulates | mentions | unrelated
    "subtype": "sector-rule",      // one of the eight subtypes, or "none"
    "reason": "…one sentence quoting the title…",
    "model": "claude-opus-5",
    "classifiedAt": "2026-09-13T18:00:00.000Z"
  }
}
```

`data/ai-law-overrides.json` — what Joël said. Same shape plus `decidedBy` and `note`, and an
override replaces the whole record rather than patching it, so a corrected label cannot be left
with the old subtype. Both files may carry `__`-prefixed documentation keys, which are never bill
ids and are ignored by every reader; the overrides file ships with a `__doc` key holding a filled-in
example.

`data/ai-law-review-list.md` is the rows worth a human look, regenerated on every run — including a
run with nothing left to label, which costs no API calls and is how to pick up a change to the rules
below. Four sections, because four different things can go wrong with a label:

| Section | Why it is there |
| --- | --- |
| Unlabelled | the model refused or the response would not parse |
| `mentions` | the ambiguous middle the model itself flagged |
| `regulates` on an adopted resolution | labelled, but never counted as a law (AL HJR51, DE HJR7) |
| `regulates` with no summary | OpenStates gave us a title and at most a one-word subject tag, and the model moved the headline number off that (MS HB1723, "Artificial intelligence; define.") |

Fill in `data/ai-law-overrides.json` from it. `--review-copy <path>` writes a second copy somewhere
else for a one-off review; `data/ai-law-review-list.md` is the only standing output.

## What the emitter does with the labels

In `scripts/emit-from-checkpoint.mjs`:

- `isAiLaw = statusBucket === 'enacted' && !isResolution && (override ?? label) === 'regulates'`
- `aiLawSubtype` = the subtype whenever the effective label is `regulates`, else `null`. An adopted
  study-commission resolution keeps its `study-body` subtype so the all-statuses view can badge it,
  but `isAiLaw` stays false.
- `state-summary.json` gains `aiLaws` per state (the count of `isAiLaw` bills). `enacted` stays in
  the JSON for the data card and the all-statuses view.

## The gate

`check-data.mjs` G12 fails the deploy on any of four things, because all four used to be silent:

- a label file that exists but does not parse (read as `{}`, which discards every human override)
- a record whose `label` is outside `regulates | mentions | unrelated`, or whose `subtype` is outside
  the nine values — a `"regulate"` typo in an override stops counting a law
- an override keyed to an id no bill has — a decision Joël made that never lands
- an enacted, title-tier bill with no label in either file
Text-tier bills (in the dataset only because OpenStates matched an AI term deep in the full bill
text, which the classifier never sees) are out of scope for the last of those. `emit` and the
classifier run the same validation and refuse to write anything when it fails. G8 additionally floors the
`aiLaws` total at 95% of the last recorded baseline, the same way it already floors bills, quotes
and `enacted`.

The weekly refresh (`scripts/refresh-deploy.sh`) runs `npm run classify` right after `fetch:bills`,
so only bills that newly reached the enacted bucket cost a call. If the classifier fails, G12 blocks
the deploy and the existing heartbeat alerts Joël. That is the human-in-the-loop guarantee.
