Skip to content

Commit a36eb63

Browse files
committed
Add temporal lifecycle page and lifecycle APIs
Introduce a Temporal Lifecycle feature and integrate annotation lifecycle primitives across docs and code. Adds docs/content: new TemporalLifecyclePage, nav entry, route, and expanded CLAUDE.md notes (including the "semantic" anchor and lifecycle helpers). Replace page-local freshness logic in blog and ConversationArc demos with exported helpers (applyAnnotationLifecycle, annotationFreshnessFor) and wire the demo UIs to the shared behavior. Code changes: add realtime lifecycleBands implementation and tests, expose lifecycle-related types/APIs (semiotic-ai / semiotic-realtime), and extend Annotation props to accept opacity and strokeDasharray so lifecycle visual treatments cascade. The changes unify banded freshness classification (bandFromAge) and provide a default visual treatment for aging/stale/expired annotations.
1 parent b4791a8 commit a36eb63

15 files changed

Lines changed: 1433 additions & 281 deletions

CLAUDE.md

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ All HOCs accept `annotations` (array). Coordinates use data field names.
239239
**Ordinal**: `category-highlight`
240240
**Enclosures**: `enclose`, `rect-enclose`, `highlight`
241241
**Statistical**: `trend`, `envelope`, `anomaly-band`, `forecast`
242-
**Streaming anchors**: `"fixed"` | `"latest"` | `"sticky"`
242+
**Streaming anchors**: `"fixed"` | `"latest"` | `"sticky"` | `"semantic"` — also exposed as `lifecycle.anchor` on the `semiotic/ai` annotation lifecycle. Same `AnnotationAnchor` type either way; `"semantic"` re-resolves via `provenance.stableId` (runtime support landing incrementally).
243243

244244
## Theming
245245

@@ -342,25 +342,63 @@ store.record({ type: "suggestion-shown", components: ["LineChart"], intent: "tre
342342
```
343343

344344
### Annotation provenance + lifecycle (`semiotic/ai`, types also re-exported from `semiotic`)
345-
Type surface for "where did this annotation come from?" and "is it stale?" Optional blocks attached to any annotation — existing arrays keep working unchanged.
345+
Tracks "where did this annotation come from?" and "is it stale?" Two optional blocks attach to any annotation — existing arrays keep working unchanged.
346346
- **`provenance`**: `{ author?, source?, confidence?, createdAt?, stableId? }`. `source` is an open string union (`"user" | "ai" | "agent" | "import" | "computed" | "system" | (string & {})`).
347347
- **`lifecycle`**: `{ freshness?, ttlHint?, anchor? }`. `freshness` is `"fresh" | "aging" | "stale" | "expired"`. `anchor` is `"fixed" | "latest" | "sticky" | "semantic"`. `ttlHint` accepts an ISO 8601 duration string (`"P30D"`) or milliseconds.
348348
- **`withProvenance(annotation, { provenance?, lifecycle? })`** → returns a new annotation with the blocks attached. Pure, SSR-safe.
349349
- **`Annotated<T>`** type alias: `T & { provenance?, lifecycle? }`. Use for explicit typing.
350-
- Type surface only at this stage. Freshness computation, default visual treatment, and stable-id anchor resolution land later.
350+
- **`computeAnnotationFreshness(annotations, { now?, dataExtent?, thresholds? })`** → returns annotations with `lifecycle.freshness` populated based on `createdAt` + `ttlHint`. `now` defaults to `dataExtent`'s max, then `Date.now()`. Custom thresholds override the default 1× / 1.5× / 3× TTL breakpoints.
351+
- **`annotationFreshnessFor(annotation, nowMs, thresholds?)`** → classifies a single annotation. Honors any pre-existing `lifecycle.freshness` (explicit assignment wins).
352+
- **`applyAnnotationLifecycle(annotations, { now?, dataExtent?, opacity?, strokeDasharray?, labelSuffix?, showExpiredAnnotations?, thresholds? })`** → composes the freshness pass with a default visual treatment: aging dims (opacity 0.55), stale dims more + dashes (strokeDasharray `"4 4"`), expired is filtered out (set `showExpiredAnnotations: true` to keep). Per-band overrides via the options; pass `null` for a band to disable that default. Annotation `opacity` / `strokeDasharray` set explicitly on the annotation win over the treatment. Annotation renderer cascades both as SVG presentation attributes to every stroked / filled child.
353+
- Still owed (M3): stable-id anchor resolution after data refresh.
351354

352355
```ts
353-
import { withProvenance } from "semiotic/ai"
356+
import { withProvenance, applyAnnotationLifecycle } from "semiotic/ai"
354357

355358
const ann = withProvenance(
356-
{ type: "y-threshold", value: 100, label: "SLA breach" },
359+
{ type: "y-threshold", value: 100, label: "SLA breach", color: "#3a8eff" },
357360
{
358361
provenance: { author: "alice", source: "user", createdAt: "2026-05-20T14:00:00Z" },
359362
lifecycle: { ttlHint: "P30D", anchor: "semantic" },
360363
},
361364
)
365+
366+
// Pass through the lifecycle treatment before handing to a chart.
367+
<LineChart annotations={applyAnnotationLifecycle([ann], { dataExtent: [t0, tNow] })} />
362368
```
363369

370+
### Temporal lifecycle (shared between `semiotic/realtime` + `semiotic/ai`)
371+
Three Semiotic systems answer "how does this thing look as it ages?" with three different policies on three different time axes. They are *not* interchangeable — pick the one that matches the question.
372+
373+
| Policy | Lives in | Time axis | Output | Scope |
374+
|---|---|---|---|---|
375+
| `DecayConfig` | `semiotic/realtime` | buffer position | continuous opacity ramp | per-datum |
376+
| `StalenessConfig` | `semiotic/realtime` | wall-clock idle | binary live/stale (+ optional badge) | chart-wide |
377+
| Annotation freshness | `semiotic/ai` | `createdAt` + `ttlHint` | 4 named bands (opacity + dashing + expired filter) | per-annotation |
378+
379+
Shared primitive that bridges them: **`bandFromAge(ageMs, ttlMs, thresholds?)`**`"fresh" | "aging" | "stale" | "expired"`. Pure function exported from both `semiotic/realtime` and `semiotic/ai`. `DEFAULT_LIFECYCLE_THRESHOLDS = { fresh: 1.0, aging: 1.5, stale: 3.0 }` (multipliers of TTL). Annotation freshness uses this primitive today; future banded-decay or banded-staleness opt-ins can plug into the same classifier.
380+
381+
**Anchor mode** (`"fixed" | "latest" | "sticky" | "semantic"`) is also shared — `AnnotationAnchor` from `semiotic/realtime` is the canonical type, re-exported from `semiotic/ai` as `lifecycle.anchor`. `"semantic"` re-resolves via `provenance.stableId` (runtime support landing incrementally).
382+
383+
**Streaming chart-time aging**: pass the chart's `dataExtent` to `applyAnnotationLifecycle` and the latest data point becomes the "now" reference. Annotations age against chart-time, not wall-clock — useful when streams pause or run slow. Pair with `withCurrentProvenance(annotation, { author?, source? })` or `currentTimestamp()` to auto-stamp `createdAt` at the moment of creation.
384+
385+
```ts
386+
import { bandFromAge, withCurrentProvenance, applyAnnotationLifecycle } from "semiotic/ai"
387+
388+
bandFromAge(20_000, 8000) // "stale" — age is 2.5× TTL
389+
390+
const ann = withCurrentProvenance(
391+
{ type: "callout", t: latest.t, value: latest.value, label: "Spike" },
392+
{ author: "alice", source: "user" },
393+
)
394+
395+
<LineChart
396+
annotations={applyAnnotationLifecycle([ann], { dataExtent: [data[0].t, data.at(-1).t] })}
397+
/>
398+
```
399+
400+
Full survey at `/intelligence/temporal-lifecycle`.
401+
364402
### Variant discovery (`semiotic/ai`)
365403
Interface for proposing and scoring chart variants beyond the hand-curated `capability.variants`. Heuristic and model-based proposers plug in through `registerVariantDiscovery`. M1 ships the type surface + stub implementations; behavior arrives in subsequent milestones.
366404
- **`VariantProposal`**: `{ id, baseComponent, intentDeltas?, rubricDeltas?, buildProps?, rationale?, source: "manual" | "heuristic" | "model", variantKey?, tags? }`.

docs/src/App.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import CapabilitiesPage from "./pages/features/CapabilitiesPage"
9797
import InterrogationPage from "./pages/features/InterrogationPage"
9898
import SuggestionsPage from "./pages/features/SuggestionsPage"
9999
import ConversationArcPage from "./pages/features/ConversationArcPage"
100+
import TemporalLifecyclePage from "./pages/features/TemporalLifecyclePage"
100101

101102
// New cookbook pages
102103
import HomerunMapPage from "./pages/cookbook/HomerunMapPage"
@@ -401,6 +402,7 @@ export default function DocsApp() {
401402
<Route path="suggestions" element={<SuggestionsPage />} />
402403
<Route path="interrogation" element={<InterrogationPage />} />
403404
<Route path="conversation-arc" element={<ConversationArcPage />} />
405+
<Route path="temporal-lifecycle" element={<TemporalLifecyclePage />} />
404406
<Route path="serialization" element={<SerializationPage />} />
405407
<Route path="vega-lite" element={<VegaLiteTranslatorPage />} />
406408
</Route>

docs/src/blog/entries/talk-track-intelligence.js

Lines changed: 22 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import React, { useEffect, useMemo, useRef, useState } from "react"
33
import { Link } from "react-router-dom"
44
import { CategoryColorProvider, DotPlot, LineChart } from "semiotic"
55
import {
6+
annotationFreshnessFor,
7+
applyAnnotationLifecycle,
68
disableConversationArc,
79
enableConversationArc,
810
getConversationArcStore,
@@ -267,59 +269,32 @@ const RAW_ANNOTATIONS = [
267269
]
268270

269271
const FRESHNESS_BASE_COLOR = { user: "#3a8eff", ai: "#d49a00" }
270-
const FRESHNESS_COLOR = {
271-
fresh: (base) => base,
272-
aging: () => "#8a96a3",
273-
stale: () => "#b0b0b0",
274-
}
275-
const FRESHNESS_SUFFIX = { fresh: "", aging: " · aging", stale: " · stale" }
276272
const FRESHNESS_BADGE = {
277273
fresh: "#2d8a4a",
278274
aging: "#d49a00",
279275
stale: "#a0a0a0",
280276
expired: "#c43d3d",
281277
}
282278

283-
function parseIsoDuration(s) {
284-
const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?)?$/.exec(s)
285-
if (!m) return 0
286-
return (parseInt(m[1] || "0", 10) * 24 + parseInt(m[2] || "0", 10)) * 60 * 60 * 1000
287-
}
288-
289-
function previewFreshness(ann, nowMs) {
290-
const created = ann?.provenance?.createdAt ? Date.parse(ann.provenance.createdAt) : null
291-
const ttl = ann?.lifecycle?.ttlHint
292-
if (created == null || ttl == null) return "fresh"
293-
const ms = typeof ttl === "number" ? ttl : parseIsoDuration(ttl)
294-
const age = nowMs - created
295-
if (age < ms) return "fresh"
296-
if (age < ms * 1.5) return "aging"
297-
if (age < ms * 3) return "stale"
298-
return "expired"
299-
}
300-
301279
function FreshnessLiveDemo() {
302280
const [nowIso, setNowIso] = useState("2026-03-10T00:00:00Z")
303281
const nowMs = Date.parse(nowIso)
304282

305-
const states = RAW_ANNOTATIONS.map((a) => ({
306-
raw: a,
307-
freshness: previewFreshness(a, nowMs),
283+
// Each annotation keeps its author's brand color via `color`. The
284+
// shipped applyAnnotationLifecycle treatment fills in opacity +
285+
// strokeDasharray per band and drops expired ones.
286+
const annotationsWithColor = RAW_ANNOTATIONS.map((a) => ({
287+
...a,
288+
color: FRESHNESS_BASE_COLOR[a.provenance.source] ?? "#5a5a5a",
289+
}))
290+
const visible = applyAnnotationLifecycle(annotationsWithColor, {
291+
now: nowMs,
292+
labelSuffix: { aging: " · aging", stale: " · stale" },
293+
})
294+
const states = RAW_ANNOTATIONS.map((raw) => ({
295+
raw,
296+
freshness: annotationFreshnessFor(raw, nowMs),
308297
}))
309-
310-
// Expired annotations drop out of the array — mirrors M2's default
311-
// of hiding them unless `showExpiredAnnotations` is on.
312-
const visible = states
313-
.filter((s) => s.freshness !== "expired")
314-
.map(({ raw, freshness }) => {
315-
const base = FRESHNESS_BASE_COLOR[raw.provenance.source] ?? "#5a5a5a"
316-
return {
317-
...raw,
318-
label: raw.label + FRESHNESS_SUFFIX[freshness],
319-
color: FRESHNESS_COLOR[freshness](base),
320-
lifecycle: { ...raw.lifecycle, freshness },
321-
}
322-
})
323298

324299
return (
325300
<div style={card}>
@@ -493,9 +468,12 @@ function Body() {
493468
<FreshnessLiveDemo />
494469

495470
<p>
496-
The styling above is page-local — the M1 surface is type-only,
497-
and the shipping <code style={inlineCode}>computeAnnotationFreshness</code>{" "}
498-
helper plus the default visual treatment land next.
471+
The styling above is the shipped default: a single call to{" "}
472+
<code style={inlineCode}>applyAnnotationLifecycle(annotations, {"{ now }"})</code>{" "}
473+
classifies each annotation, dims aging, dashes stale, and drops
474+
expired from the array. The author's brand color survives the
475+
treatment — provenance stays visible while age signals layer on
476+
top.
499477
</p>
500478

501479
<h2>Variants the library didn't think of</h2>

docs/src/components/navData.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ const navData = [
130130
{ title: "Chart Suggestions", path: "/intelligence/suggestions" },
131131
{ title: "Interrogation", path: "/intelligence/interrogation" },
132132
{ title: "Conversation Arc", path: "/intelligence/conversation-arc" },
133+
{ title: "Temporal Lifecycle", path: "/intelligence/temporal-lifecycle" },
133134
{ title: "Serialization", path: "/intelligence/serialization" },
134135
{ title: "Vega-Lite Translator", path: "/intelligence/vega-lite" }
135136
]

0 commit comments

Comments
 (0)