Skip to content

Commit aa98212

Browse files
committed
Add annotation lifecycle docs and runtime fixes
Expand documentation and API for annotation provenance/lifecycle and temporal lifecycle behavior; adjust runtime logic and tests. - Docs: update .clinerules, .cursorrules, .windsurfrules, .github/copilot-instructions.md, and docs/public/llms-full.txt to document the new "semantic" anchor mode and a richer annotation lifecycle (compute/apply helpers, bandFromAge, thresholds, streaming chart-time usage). Add TemporalLifecyclePage demo updates and explanatory text. - API surface: export new symbols (DEFAULT_LIFECYCLE_THRESHOLDS, bandFromAge, computeAnnotationFreshness, annotationFreshnessFor, applyAnnotationLifecycle, withCurrentProvenance, lifecycle-related types/options) in semiotic-ai and semiotic-realtime api surface files. - Runtime: applyAnnotationLifecycle now mirrors lifecycle.anchor onto the top-level anchor (unless caller set top-level anchor explicitly) and types adjusted to include anchor; streaming demo uses chart-time as the reference and attaches TTL hints to annotations. - bandFromAge: tighten edge-case handling (NaN treated as fresh, +Infinity as expired, negative ages as fresh, non-positive/non-finite TTLs treated as fresh) and update tests accordingly. - Tests: add tests to assert anchor mirroring and precedence; update lifecycle band tests to reflect NaN/Infinity behavior. These changes enable banded annotation aging, chart-time-based aging for streaming demos, and ensure streaming resolvers pick up lifecycle anchor hints.
1 parent a36eb63 commit aa98212

12 files changed

Lines changed: 334 additions & 58 deletions

File tree

.clinerules

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? }`.

.cursorrules

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? }`.

.github/copilot-instructions.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? }`.

0 commit comments

Comments
 (0)