Three Semiotic systems answer the same underlying question —"how does this thing look as it ages?" — with three different policies on three different time axes:
Decay
continuous · per-datum · buffer position
A continuous opacity ramp keyed to position in the streaming buffer. Older points fade smoothly.
→ Realtime Encoding
Staleness
binary · chart-wide · wall-clock idle
Live until the chart hasn't seen data for a configured threshold, then dimmed (plus optional LIVE/STALE badge).
→ Realtime Encoding
Annotation freshness
banded · per-annotation · createdAt + ttlHint
Four named bands — fresh, aging, stale, expired — with opacity + dashing defaults and an expired filter.
→ Conversation Arc
The shared primitive that ties them together isbandFromAge(ageMs, ttlMs, thresholds?) — a pure function that classifies an age into one of four named bands. Annotation freshness uses it today; the other two systems can opt into banded classification when a binary or continuous policy doesn't fit.
The classification primitive
Drag the slider to advance the simulated age. The active band is what bandFromAge returns for that age, given an 8-second TTL:
age: 0.0sTTL: 8sband: fresh
TS
import { bandFromAge } from "semiotic/ai" // or "semiotic/realtime" bandFromAge(0, 8000) // "fresh" bandFromAge(7_000, 8000) // "fresh" bandFromAge(10_000, 8000) // "aging" (age >= 1.0 × TTL) bandFromAge(20_000, 8000) // "stale" (age >= 1.5 × TTL) bandFromAge(30_000, 8000) // "expired" (age >= 3.0 × TTL) // Custom thresholds — multipliers of TTL, not raw ms: bandFromAge(20_000, 8000, { fresh: 2, aging: 3, stale: 6 }) // "fresh"
Streaming chart-time aging
For time-series charts, the chart's own data is its clock — the latest data point's timestamp is a more honest "now" than wall-clock, because it tracks chart-time even when the stream pauses or runs slow. applyAnnotationLifecycleaccepts a dataExtent option that derives "now" from the latest value automatically.
Stamp the latest point, then let the stream run. The annotation has a 3-second TTL — at 3s past the latest data point it goesaging, at 4.5s stale (dashed), at 9s it's filtered out. Pause the stream and the annotation stops aging too: chart-time is the data, not the wall.
TSX
import { applyAnnotationLifecycle, withCurrentProvenance, } from "semiotic/ai" // Stamp createdAt at the moment of creation; attach a ttlHint so the // lifecycle pass has something to age against. const ann = withCurrentProvenance( { type: "callout", t: latestTimestamp, value: latestValue, label: "Spike", lifecycle: { ttlHint: 3000 }, // 3 seconds }, { author: "you", source: "user" } ) // Pass the chart's data extent so "now" tracks chart-time. When the // stream pauses, the latest data timestamp stops advancing, and the // annotation stops aging too. <LineChart data={streamingData} xAccessor="t" yAccessor="value" annotations={applyAnnotationLifecycle([ann], { dataExtent: [streamingData[0].t, streamingData.at(-1).t], })} />
Picking a policy
The three policies aren't interchangeable — each fits a different question:
- Decay when older data should still be visible but progressively de-emphasized. Per-datum, continuous, drives line tails / trail effects.
- Staleness when there's a wall-clock cutoff after which the whole chart should look not-live. Chart-wide, binary, drives oncall dashboards and incident displays.
- Annotation freshness when a single mark on the chart has a TTL of its own that doesn't track the chart's data rhythm — a Q3 retrospective note, an SLA breach marker, an AI-suggested anomaly tag.
All three can coexist on one chart. A streaming dashboard might decay older points, mark itself stale when the stream stops, and independently age a user-placed annotation against its TTL. The only piece they share today is the classifier — the rest of the plumbing is intentionally separate so the policies don't drag each other's edge cases around.