# Event Uncertainty

A general-purpose system for rippers to signal that they couldn't
determine one or more fields of an event (a start time, a duration, a
location, an image), so the build can publish the event with a
placeholder while an LLM-driven resolver fills in the truth on a later
build.

Same shape as the geo-cache pattern: structured signals in
`build-errors.json` → an agent skill investigates → the committed
`event-uncertainty-cache.json` records resolutions (via PR) → the next
build applies them.

## Why

Roughly half of the events from `events12` had no time on the source
page. The old behavior silently defaulted to noon for 2 hours, so the
calendar quietly told subscribers the wrong time for ~50 events at a
time. Defaulting to noon is **a guess that looks like a fact**. The
choice was either:

1. Drop the events.
2. Keep guessing.
3. Publish a placeholder, signal the uncertainty, and let an LLM fix it.

This system is option 3.

## Anatomy

### Ripper output

A ripper that can't determine a field emits **two** items with the same
`event.id`:

1. The `RipperCalendarEvent` itself, with whatever placeholder it picked.
2. An `UncertaintyError` carrying the **full event** (not a subset of
   fields) plus a `unknownFields` array and an optional
   `partialFingerprint`.

Keeping the embedded event whole means that if `RipperCalendarEvent`
grows new fields, the resolver agent automatically sees them — no
schema migration here.

**Best practice — one UncertaintyError per event.** When multiple
fields are unknown for the same event, combine them into a single
error with `unknownFields: ["startTime", "location"]` rather than
emitting one error per field. The merge function tolerates the
multi-error case (it'll consult the cache once and apply to all), but
single-error-per-event keeps the work queue, the cache key, and the
resolver agent's job simpler. The events12 ripper is the canonical
example: a single missing time produces one error per (event-day, slot)
occurrence — not one error per field per occurrence.

### Infrastructure merge

`lib/calendar_ripper.ts` calls `applyUncertaintyResolutions` for every
ripper after rip but before write. It:

- looks up `{source}:{event.id}` in `event-uncertainty-cache.json`,
- if **resolved**: applies the cached fields, drops the error,
- if **unresolvable**: drops the error, appends a "could not be verified"
  note to `description`,
- if **miss**: keeps the error, appends an "approximate — verification
  pending" note.

The pure function lives in `lib/uncertainty-merge.ts`; rippers don't
touch the cache, so no per-ripper plumbing is needed when new rippers
opt into the system.

### Cache

`event-uncertainty-cache.json` — the **committed file in the repo is the
cache** (no S3). CI reads it at build start; the resolver edits it and
opens a PR. A 90-day `event-uncertainty-cache` artifact is uploaded every
build as a snapshot, but the committed file is the source of truth. See
`docs/github-native-caches.md` for the full persistence model.

Schema:

```json
{
  "version": 1,
  "entries": {
    "events12:family-christmas-2025-12-01": {
      "fields": { "startTime": "16:00", "duration": 10800 },
      "resolvedAt": "2026-05-17",
      "source": "agent",
      "evidence": "https://events12.com/seattle/event/118800/",
      "partialFingerprint": "abc-d6-tu"
    },
    "events12:some-gone-event-2026-03-01": {
      "unresolvable": true,
      "reason": "Source page no longer lists this event",
      "resolvedAt": "2026-05-17",
      "source": "agent"
    }
  }
}
```

### Reporting

The system is only useful if every reporting surface knows about it.
`build-errors.json` carries the canonical data; everything else reads
from there:

| Surface | Output |
|---|---|
| `build-errors.json` | `uncertainEvents[]` (work queue), `uncertaintyStats` (counters), errors with `type: "Uncertainty"` per source |
| PR comment | `❓ Uncertain events:` line + collapsible breakdown of up to 20 entries |
| Main build step summary | Same line in `$GITHUB_STEP_SUMMARY` |
| Discord (main build success) | Inline count + nudge to run the resolver skill |
| Website health dashboard | "Uncertain Events" card + per-entry table with links to source pages |
| Daily build-report skill | Hands off to `event-uncertainty-resolver` when outstanding > 0 |

**Rule of thumb**: any new error category must be plumbed through all
of these in the same PR. See AGENTS.md → "Reporting parity".

### The resolver skill

`skills/event-uncertainty-resolver/SKILL.md` plus
`skills/event-uncertainty-resolver/scripts/uncertainty-cache.py` (a
mirror of `geo-cache.py`). The agent's loop:

1. `stats` — get the counts
2. `outstanding` — pull the work queue
3. For each entry: `WebFetch` the source URL, find the field, call
   `resolve` with the verified value (or `--unresolvable` with a
   reason)
4. Trigger a new build (or wait for the daily cron); the next build
   shows the corrected event.

### Cache invalidation

The `partialFingerprint` is a hash of whatever the ripper *did* parse
from the source. When upstream content changes (e.g., a real start time
finally gets published), the fingerprint changes and the cached
resolution is treated as a miss — the resolver re-investigates against
the new content. Without this, stale resolutions would silently
overwrite correct upstream values.

## Counting and CI

Outstanding uncertain events **count toward `totalErrors`** so they
don't get forgotten, but every reporting surface breaks them out as
their own category. They are **not fatal** — they don't block CI.

Rationale: uncertainty errors are todos, not failures. Letting them
fail CI would gate every PR on an out-of-band agent run. Treating them
as silent / informational would let them accumulate forever. The middle
ground (visible in every report but non-fatal) keeps the queue
shrinking without blocking development.

## Stable event IDs

Every ripper that opts into this system must produce **stable event
IDs derived from source content only** (no `Date.now()`, no array
indices, no randomness). The id is the cache join key — if it changes
between builds, all prior resolutions become orphans.

See AGENTS.md → "Ripper design: stable event IDs" for the rule.

## When to opt in

Opt a ripper into the system when:
- the upstream source legitimately omits a field on some events, and
- the alternative is silently guessing (e.g., defaulting time to noon).

Don't opt in when:
- the field is genuinely unrecoverable (e.g., the source page is
  cryptic and even a human couldn't tell).
- the missing field doesn't affect the event's usefulness (e.g., a
  missing image is fine; don't burn cache entries on it unless you
  want one).

## Future fields

Adding a new uncertain field (e.g. `description`):

1. Add it to `UncertaintyField` in `lib/config/schema.ts`.
2. Add the apply logic in `applyResolution` in `lib/uncertainty-merge.ts`.
3. Add the CLI flag in `skills/event-uncertainty-resolver/scripts/uncertainty-cache.py`.
4. Document the field shape in `SKILL.md`'s field reference table.

The embedded-event design in `UncertaintyError` means the new field is
already visible to the resolver agent without further schema changes.

## Image backfill (overlay, no UncertaintyError required)

`imageUrl` is a special case. Most events that lack a photo never emit an
`UncertaintyError` for it — flooding the `uncertainEvents` queue with every
image-less event would drown the real "unknown start time" work. Instead,
images are backfilled via an **overlay** pass:

- `applyImageBackfill` in `lib/uncertainty-merge.ts` runs after
  `applyUncertaintyResolutions` in the build. For every event with **no**
  `imageUrl`, it looks up `${source}:${event.id}` in the same
  `event-uncertainty-cache.json` and applies `fields.imageUrl` if present.
- It never overwrites an existing `imageUrl`, and skips entries marked
  `unresolvable` (a recorded "no image available" — the event stays
  image-less and drops off the gap report without re-investigation).
- The cache is populated by the **photo-resolver skill** via the same
  `uncertainty-cache.py resolve --image-url` flow used for other fields.

The work queue for image backfill is **not** the uncertainty queue — it's the
`photoGaps` section of `build-errors.json` (see `docs/photos.md`).

## Cost backfill (overlay, no UncertaintyError required)

`cost` follows the same overlay pattern as images — pervasively missing, so
costless events never emit an `UncertaintyError`:

- `applyCostBackfill` in `lib/uncertainty-merge.ts` fills `cost` from
  `fields.cost` in the same cache (written via `--cost-min`/`--cost-max`,
  `--cost-free`, or `--cost-paid-unknown`). It never overwrites a
  ripper-parsed cost and skips `unresolvable` entries. The source-level YAML
  `cost:` default (`attachEventCost`) runs after the overlay, so precedence
  is: ripper-parsed → cache resolution → YAML default.
- The work queue is the `costGaps` section of `build-errors.json`, drained by
  the **cost-resolver skill** (`skills/cost-resolver/SKILL.md`), which embeds
  the pricing rubric. See `docs/free-paid-filter.md` for the full design.

Note the cache's `unresolvable` flag is **entry-global**: one entry per
`source:eventId`, shared by all fields. Marking an event unresolvable for
cost also stops photo backfill and acknowledges open uncertainties for it —
the cost-resolver skill therefore prefers `{ paid: true }` (a real value)
over `unresolvable` unless the event page is a dead end overall.

## Setting backfill (overlay + a venue-level layer)

`setting` (`outdoor`/`indoor`/`covered` — weather-badge eligibility, see
`docs/weather-badges.md`) follows the same overlay pattern as images/costs,
with one addition: a **venue-level key namespace** in the same cache.

- `applySettingBackfill` in `lib/uncertainty-merge.ts` fills per-event
  `setting` from `fields.setting` (written via `--setting`). It never
  overwrites a ripper-provided setting and skips `unresolvable` entries.
- **Venue-level entries** — a venue's indoor/outdoor nature is a fact about
  the *place*, so it is cached once under `venue:osm:<type>:<id>` (preferred;
  events carry OSM identity from geocoding) or `venue:loc:<normalized
  location>` (the geo-cache's key normalization), and inherited by every
  event from **any** source that resolves to that venue
  (`lookupVenueSetting` in `lib/event-uncertainty-cache.ts`). Same entry
  shape, same CLI, same lastSeen/prune tooling.
- Badge-time precedence (`resolveEventSetting` in `lib/weather.ts`):
  per-event → venue → channel `Outdoors` tag → unknown (no badge).
- The work queue is the `settingGaps` section of `build-errors.json`
  (venue-first: events sharing a venue key collapse into one gap), scoped to
  sources declaring `weatherSetting: "mixed"` and drained by the
  **setting-resolver skill** (`skills/setting-resolver/SKILL.md`).
