feat(web): persona pane renders the relation_edge/1 affect model + per-value trend (v0.19.4)
The persona/affect pane read snap.valence (the pre-#265 shape) while Worldtree now emits snap.relations (relation_edge/1) — so the whole trust/warmth model rendered as an empty "valence (0)". Now renders the real signal, self-labelled: - MOOD (PAD, transient): pleasure/arousal/dominance with a one-word descriptor each. - RELATION → <target> (stage: <relation_context>): trust·ability / benevolence / integrity + warmth, each as value + evidence_count (n=) — the durable social model. - Per-value TREND: Δ-vs-previous (▲/▼) + a unicode sparkline auto-scaled to the value's own observed range (flat when sub-0.01 stable, so noise isn't amplified). History accumulates client-side, one sample/turn (deduped by emitted_at), capped at 24. - Falls back to the legacy snap.valence for an older emitter; INV-001 (no fabricated Tier-1 fields) + INV-004 (every cell escaped) preserved. Supersedes the #18-D2 valence assumption + retires the stale "regard dead axis" note. Verified: render logic asserted in node against the REAL affect.db snapshot + a perturbed 2nd sample (relations rendered, no "valence (0)", Δ ▲ shown, 2-char sparkline builds, INV-004 holds). JS syntax clean. No server change (static served per-request) — refresh + drive turns to watch the trends build.
This commit is contained in:
@@ -105,6 +105,16 @@ functions:
|
||||
- "POST-004: the Tools inventory renders tool NAMES only — a compact comma-joined summary ('what does the LLM have', the debug glance); per-tool DESCRIPTIONS are surfaced in the BifrostState pane's tools list, deliberately NOT duplicated here. (Heid panel Hulda/Regin precision finding — accepted: contract wording clarified, code unchanged; the earlier 'names/descriptions' phrasing in INV-004 refers to the SET of value types that MAY appear across the new panes and must be escaped, not a mandate that every pane render descriptions.)"
|
||||
flexibility: "open"
|
||||
|
||||
- name: "renderAffectPane + trend (v0.19.4 — relation_edge/1 render + sparkline)"
|
||||
signature: "renderAffectPane(snap) ; pushAffectHistory(snap) ; sparkline(vals) ; trendDelta(vals)"
|
||||
description: "Render the Tier-3 affect snapshot as PAD mood + the durable per-entity relational model, each value with a Δ-vs-previous + a session-lived sparkline."
|
||||
postconditions:
|
||||
- "POST-001: reads snap.relations (relation_edge/1: target_entity + trust_ability/benevolence/integrity + warmth as {value,confidence,evidence_count} + agency + relation_context + obligation_balance) — the CURRENT Worldtree emit shape; falls back to the legacy flat snap.valence for an older emitter. SUPERSEDES the #18-D2 contract's valence assumption (Worldtree's #265 Vili rework replaced valence/regard with the relation_edge/trust model; the old renderer read snap.valence and showed an empty 'valence (0)' — the bug this fixes)."
|
||||
- "POST-002: each metric shows current value + Δ-vs-previous (▲/▼) + a unicode sparkline auto-scaled to its OWN observed range (flat ▄ when sub-0.01 stable — no noise amplification), drawn from AFFECT_HIST (rolling, HIST_CAP=24, session-lived)."
|
||||
- "POST-003: pushAffectHistory dedupes by emitted_at so the ~4x/turn post-turn PAD poll contributes ONE sample/turn; history is CLIENT-side only (lost on reload — durable cross-session history via a provider-side snapshot log is a deferred follow-up, NOT built here)."
|
||||
- "POST-004: INV-001 honesty — no fabricated Tier-1 fields (no synthesized dominant_emotion). INV-004 — head() escapes its whole argument (incl. target_entity + relation_context from the snapshot) and metric() escapes every cell; numeric values go through toFixed, never innerHTML-raw."
|
||||
flexibility: "open"
|
||||
|
||||
invariants:
|
||||
- "INV-004 (untrusted-render): ALL model / tool / admin / agent-supplied text is escaped before entering the DOM (esc via textContent, or esc(JSON.stringify)). No new render path introduces an innerHTML sink for upstream content. This is the highest-value review target — the new JS render paths are NOT unit-tested."
|
||||
- "INV-ADMIN-KEY: the admin key exists ONLY at app.state.admin_key (from RATATOSKR_ADMIN_API_KEY). It is never serialized into any response, never sent to the browser, never logged. The browser receives only the session-filtered RESULT of admin-scoped reads."
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.19.3"
|
||||
version = "0.19.4"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -361,7 +361,22 @@ body {
|
||||
/* persona pane structured render */
|
||||
#pane-persona .pk { color: var(--fg-dim); }
|
||||
#pane-persona .pv { color: var(--blue); }
|
||||
#pane-persona .ph { color: var(--cyan); letter-spacing: 0.1em; text-transform: uppercase; font-size: 10px; }
|
||||
#pane-persona .ph { color: var(--cyan); letter-spacing: 0.1em; text-transform: uppercase; font-size: 10px; margin-top: 4px; }
|
||||
|
||||
/* affect metric rows: label · value · Δ · sparkline · n · descriptor */
|
||||
#pane-persona .mono-note { color: var(--fg-faint); font-size: 10px; margin: 2px 0 8px; }
|
||||
#pane-persona .mrow {
|
||||
display: flex; gap: 9px; align-items: baseline; padding: 1px 0;
|
||||
font-size: 12px; white-space: nowrap;
|
||||
}
|
||||
#pane-persona .mrow .mk { color: var(--fg-dim); min-width: 118px; }
|
||||
#pane-persona .mrow .mv { color: var(--blue); min-width: 46px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
#pane-persona .mrow .md { min-width: 58px; font-size: 11px; color: var(--fg-faint); }
|
||||
#pane-persona .mrow .md.up { color: var(--green); }
|
||||
#pane-persona .mrow .md.dn { color: var(--red); }
|
||||
#pane-persona .mrow .msp { color: var(--cyan); letter-spacing: 1px; min-width: 28px; }
|
||||
#pane-persona .mrow .mn { color: var(--fg-faint); font-size: 10px; min-width: 34px; }
|
||||
#pane-persona .mrow .mdesc { color: var(--fg-faint); font-style: italic; font-size: 10px; }
|
||||
|
||||
/* thinking-pane per-turn dividers */
|
||||
.pane-turn {
|
||||
@@ -807,25 +822,102 @@ async function loadPersona(agentId) {
|
||||
// shape only — pad + per-entity valence + emitted_at; NO fabricated Tier-1 persona
|
||||
// fields (dominant_emotion / mood_drift), which Tier-3 structurally lacks (INV-001).
|
||||
// Labelled "affect", not "persona" (INV-005).
|
||||
// ---- affect trend accumulation (client-side, session-lived) ----
|
||||
// The affect pane refreshes on session-open + after each turn (the post-turn PAD poll
|
||||
// fires ~4x/turn — deduped here by emitted_at so a turn contributes ONE sample). Each
|
||||
// tracked value keeps a rolling, capped history so the pane can show a Δ + sparkline.
|
||||
const AFFECT_HIST = { at: [], pad: {}, rel: {} }; // pad[axis]=[]; rel[target][metric]=[]
|
||||
const HIST_CAP = 24;
|
||||
const _relVal = (x) => (x && typeof x === "object" && "value" in x) ? x.value : x;
|
||||
const _relN = (x) => (x && typeof x === "object" && "evidence_count" in x) ? x.evidence_count : undefined;
|
||||
function pushAffectHistory(snap) {
|
||||
const at = snap.emitted_at || "";
|
||||
if (at && AFFECT_HIST.at[AFFECT_HIST.at.length - 1] === at) return; // same snapshot — skip
|
||||
AFFECT_HIST.at.push(at);
|
||||
if (AFFECT_HIST.at.length > HIST_CAP) AFFECT_HIST.at.shift();
|
||||
const push = (bucket, key, v) => {
|
||||
if (typeof v !== "number") return;
|
||||
(bucket[key] = bucket[key] || []).push(v);
|
||||
if (bucket[key].length > HIST_CAP) bucket[key].shift();
|
||||
};
|
||||
const pad = snap.pad || {};
|
||||
for (const ax of ["pleasure", "arousal", "dominance"]) push(AFFECT_HIST.pad, ax, pad[ax]);
|
||||
for (const rel of (snap.relations || snap.valence || [])) {
|
||||
const tgt = rel.target_entity || rel.entity_id || "?";
|
||||
const b = (AFFECT_HIST.rel[tgt] = AFFECT_HIST.rel[tgt] || {});
|
||||
push(b, "trust_ability", _relVal(rel.trust_ability));
|
||||
push(b, "trust_benevolence", _relVal(rel.trust_benevolence));
|
||||
push(b, "trust_integrity", _relVal(rel.trust_integrity));
|
||||
push(b, "warmth", _relVal(rel.warmth ?? rel.familiarity));
|
||||
}
|
||||
}
|
||||
// unicode sparkline auto-scaled to the value's own observed range; flat when stable
|
||||
// (don't amplify sub-0.01 noise into a fake trend).
|
||||
const _SPARK = "▁▂▃▄▅▆▇█";
|
||||
function sparkline(vals) {
|
||||
if (!vals || vals.length < 2) return (vals && vals.length) ? "·" : "";
|
||||
const lo = Math.min(...vals), hi = Math.max(...vals);
|
||||
if (hi - lo < 0.01) return "▄".repeat(vals.length);
|
||||
const span = hi - lo;
|
||||
return vals.map((v) => _SPARK[Math.min(7, Math.floor(((v - lo) / span) * 7.999))]).join("");
|
||||
}
|
||||
function trendDelta(vals) {
|
||||
if (!vals || vals.length < 2) return "";
|
||||
const d = vals[vals.length - 1] - vals[vals.length - 2];
|
||||
if (Math.abs(d) < 0.0005) return "→";
|
||||
return (d > 0 ? "▲+" : "▼") + d.toFixed(3);
|
||||
}
|
||||
|
||||
// Issue #18 D2 (relation_edge/1 rework): Worldtree's affect snapshot now carries
|
||||
// `relations[]` (target + trust_ability/benevolence/integrity + warmth + agency +
|
||||
// relation_context, each {value,confidence,evidence_count}) — NOT the old flat
|
||||
// `valence[]`. Render mood (PAD) + the durable per-entity relational model, each with
|
||||
// a Δ + sparkline from AFFECT_HIST. Falls back to `valence` for an older emitter.
|
||||
// INV-001: no fabricated Tier-1 fields. INV-004: every dynamic value escaped (head()
|
||||
// escapes its whole argument; metric() escapes each cell).
|
||||
function renderAffectPane(snap) {
|
||||
const row = (k, v) => `<div><span class="pk">${esc(k)}</span> <span class="pv">${esc(v)}</span></div>`;
|
||||
const head = (t) => `<div class="ph">${esc(t)}</div>`;
|
||||
const all = snap.valence || [];
|
||||
const shown = all.slice(0, 8); // bounded render — valence[] is unbounded in principle
|
||||
const valRows = shown.map((v) =>
|
||||
row(v.entity_id || "?",
|
||||
`familiarity ${JSON.stringify(v.familiarity)} · regard ${JSON.stringify(v.regard)}`
|
||||
+ ` · n=${JSON.stringify(v.interaction_count)}`)
|
||||
).join("");
|
||||
$("pane-persona").innerHTML =
|
||||
head("affect snapshot · " + (snap.agent_id || "?")) +
|
||||
`<div> </div>` + head("pad") +
|
||||
row("pleasure", JSON.stringify(snap.pad?.pleasure)) +
|
||||
row("arousal", JSON.stringify(snap.pad?.arousal)) +
|
||||
row("dominance", JSON.stringify(snap.pad?.dominance)) +
|
||||
`<div> </div>` + head("valence (" + all.length + ")") +
|
||||
(valRows || `<div class="empty">none</div>`) +
|
||||
`<div> </div>` + row("emitted_at", snap.emitted_at || "?");
|
||||
const num = (v) => (typeof v === "number") ? ((v >= 0 ? "+" : "") + v.toFixed(3)) : "—";
|
||||
const metric = (label, val, hist, desc, n) => {
|
||||
const d = trendDelta(hist);
|
||||
const dcls = d.startsWith("▲") ? "up" : d.startsWith("▼") ? "dn" : "";
|
||||
const nlab = (n !== undefined && n !== null) ? "n=" + esc(n) : "";
|
||||
return `<div class="mrow"><span class="mk">${esc(label)}</span>`
|
||||
+ `<span class="mv">${esc(num(val))}</span>`
|
||||
+ `<span class="md ${dcls}">${esc(d)}</span>`
|
||||
+ `<span class="msp">${esc(sparkline(hist))}</span>`
|
||||
+ `<span class="mn">${nlab}</span>`
|
||||
+ `<span class="mdesc">${esc(desc)}</span></div>`;
|
||||
};
|
||||
const pad = snap.pad || {}, H = AFFECT_HIST;
|
||||
let html = head("affect · " + (snap.agent_id || "?"))
|
||||
+ `<div class="mono-note">emitted ${esc((snap.emitted_at || "?").slice(11, 19))} · `
|
||||
+ `${H.at.length} sample${H.at.length === 1 ? "" : "s"} this session</div>`
|
||||
+ head("mood · PAD (transient, −1‥+1)")
|
||||
+ metric("pleasure", pad.pleasure, H.pad.pleasure, "feeling")
|
||||
+ metric("arousal", pad.arousal, H.pad.arousal, "activation")
|
||||
+ metric("dominance", pad.dominance, H.pad.dominance, "control");
|
||||
const rels = snap.relations || snap.valence || [];
|
||||
if (!rels.length) {
|
||||
html += head("relations") + `<div class="empty">no relations tracked yet — take a turn</div>`;
|
||||
}
|
||||
for (const rel of rels.slice(0, 8)) {
|
||||
const tgt = rel.target_entity || rel.entity_id || "?";
|
||||
const ctx = rel.relation_context ? " · stage: " + rel.relation_context : "";
|
||||
const b = H.rel[tgt] || {};
|
||||
html += head("relation → " + tgt + ctx)
|
||||
+ metric("trust·ability", _relVal(rel.trust_ability), b.trust_ability, "is-competent", _relN(rel.trust_ability))
|
||||
+ metric("trust·benevolence", _relVal(rel.trust_benevolence), b.trust_benevolence, "means-well", _relN(rel.trust_benevolence))
|
||||
+ metric("trust·integrity", _relVal(rel.trust_integrity), b.trust_integrity, "is-honest", _relN(rel.trust_integrity))
|
||||
+ metric("warmth", _relVal(rel.warmth ?? rel.familiarity), b.warmth, "affection", _relN(rel.warmth));
|
||||
const ag = _relVal(rel.agency), agn = _relN(rel.agency);
|
||||
if (typeof ag === "number" && agn) html += metric("agency", ag, null, "autonomy", agn);
|
||||
if (rel.obligation_balance !== null && rel.obligation_balance !== undefined) {
|
||||
html += `<div class="mrow"><span class="mk">obligation</span>`
|
||||
+ `<span class="mv">${esc(JSON.stringify(rel.obligation_balance))}</span></div>`;
|
||||
}
|
||||
}
|
||||
$("pane-persona").innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadAffect(agentId) {
|
||||
@@ -833,6 +925,7 @@ async function loadAffect(agentId) {
|
||||
const r = await fetch("/api/affect/" + encodeURIComponent(agentId));
|
||||
if (r.status === 200) {
|
||||
const snap = await r.json();
|
||||
pushAffectHistory(snap); // accumulate the per-value trend BEFORE rendering
|
||||
renderAffectPane(snap);
|
||||
setPersonaStrip(snap); // pad bars are the live signal
|
||||
state.lastAffectAt = snap.emitted_at || state.lastAffectAt; // post-turn poll stop-signal
|
||||
|
||||
Reference in New Issue
Block a user