diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 52964fc..38277f0 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -332,6 +332,12 @@ .drawer h3 { margin: 18px 0 6px; } .hist-h { padding-top: 12px; border-top: 1px solid var(--line); } .mini-chart svg { width: 100%; height: auto; display: block; } + .learning { margin-bottom: 14px; } + .learn-cols { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); } + @media (max-width: 860px) { .learn-cols { grid-template-columns: minmax(0, 1fr); } } + .learn-chart h4 { font-size: 12px; font-weight: 500; margin: 0 0 4px; color: var(--fg-2); } + .learn-chart .card { margin-bottom: 2px; padding: 8px 10px; } + .learn-chart p { margin: 0 0 10px; } .diagram-wrap { overflow-x: auto; margin: 4px 0 8px; } .diagram-wrap figure { margin: 0; } .md.tldr { font-size: 13px; } @@ -636,6 +642,7 @@

Links

Loop how the supervisor notices, makes sense of, decides and acts

+
@@ -2099,6 +2106,9 @@

${esc(g.title return empty(ask ? `No goals yet. The supervisor has asked for them: answer it in the Inbox.` : 'No goals yet. The supervisor asks the owner for them first (process/planning.md §1).'); })()); + // Is it learning: the chart above the tabs + $('learning').innerHTML = learningSection(o); + // Observe $('observations').innerHTML = o.observations.length ? o.observations.slice(0, 40).map((x) => `
${esc(words(x.kind))}${subjectLink(x.subject)} @@ -2535,10 +2545,13 @@

${esc(g.title return `

${esc(label)}: the TL;DR

${md}

Read the whole design

`; } const reviewList = (rs) => (rs?.length ? `
    ${rs.map((r) => `
  • ${esc(words(r.stage))} review, pass ${esc(r.pass)}: ${r.verdict === 'APPROVE' ? 'approved' : r.verdict === 'CHANGES' ? 'changes asked' : esc(r.verdict || 'not done')}${r.reviewer ? ` by ${esc(r.reviewer)}` : ''}
  • `).join('')}
` : ''); +/* learning start */ // A small time chart: one line per series over time, optional horizontal marks (targets). // span: [from, to] in ms to fix the x axis (a sensor's last 24h); width: the drawing's own width, -// so a narrow card draws a narrow chart with readable text; a series may name its color. -function lineChart(series, { marks = [], unit = '', height = 160, yMin = 0, span = null, width = 640 } = {}) { +// so a narrow card draws a narrow chart with readable text; a series may name its color. A point +// is [ts, value] or [ts, value, { hollow, href, title }]: hollow for too few to say, href to open +// what it counts. xmarks: [{ at, label, href }] draws dashed vertical lines (a retro, a rung change). +function lineChart(series, { marks = [], xmarks = [], unit = '', height = 160, yMin = 0, span = null, width = 640 } = {}) { const pts = series.flatMap((s) => s.points); if (!pts.length) return ''; const W = width, H = height, L = 40, R = 12, T = 12, B = 22; @@ -2564,10 +2577,71 @@

${esc(g.title series.forEach((s, i) => { const ps = s.points.slice().sort((a, b) => a[0] - b[0]); svg += ``; - if (ps.length <= 60) for (const p of ps) svg += `${esc(`${s.name}: ${p[1]}${unit} · ${fmtDate(p[0])}`)}`; + const c = s.color || `var(--series-${(i % 4) + 1}, var(--blue))`; + if (ps.length <= 60) for (const p of ps) { + const o = p[2] || {}; + const dot = `${esc(o.title ?? `${s.name}: ${p[1]}${unit} · ${fmtDate(p[0])}`)}`; + svg += o.href ? `${dot}` : dot; + } }); + for (const m of xmarks) { + const ts = Date.parse(m.at); + if (!(ts >= t0 && ts <= t1)) continue; + const X = x(ts).toFixed(1); + const mark = `${esc(`${m.label} · ${fmtDate(ts)}`)}`; + svg += m.href ? `${mark}` : mark; + } return `
${series.length > 1 ? `
${series.map((s, i) => `${esc(s.name)}`).join('')}
` : ''}${svg}
`; } +// Is the loop making the system smarter? Six small charts, two per column (Informed, Autonomous, +// Trusted), from the weekly learning records, oldest first. Three columns, never one score: a +// correction in one class shows as a dip, not averaged away by a promotion in another. A count and +// a share never share a chart. A point whose denominator is under LEARN_MIN is hollow: too few to say. +const LEARN_MIN = 5; +const learnShare = (a, b) => (b ? Math.round((100 * a) / b) : 0); +const LEARN_CHARTS = [ + ['Informed', [ + { title: 'Accepted knowledge', note: 'lessons, facts and rules in force at the week\'s end', series: [{ name: 'accepted knowledge', v: (f) => f.informed.knowledge }] }, + { title: 'Explained, and given', note: 'recurring signals explained, of those formed; fresh starts given a lesson', unit: '%', series: [ + { name: 'signals explained', v: (f) => learnShare(f.informed.signals_explained, f.informed.signals_formed), n: (f) => f.informed.signals_formed }, + { name: 'starts given a lesson', v: (f) => learnShare(f.informed.starts_given, f.informed.starts), n: (f) => f.informed.starts }, + ] }, + ]], + ['Autonomous', [ + { title: 'Ladder calls made on trust', note: 'of the calls in a trust-ladder class, the ones made on its rung or a standing rule, not asked', unit: '%', series: [{ name: 'on trust', v: (f) => learnShare(f.autonomous.trusted_calls, f.autonomous.ladder_calls), n: (f) => f.autonomous.ladder_calls }] }, + { title: 'Questions per merged ticket', note: 'decisions put to you, for each ticket merged', series: [{ name: 'questions per merged ticket', v: (f) => +(f.autonomous.questions / Math.max(1, f.autonomous.merged)).toFixed(2), n: (f) => f.autonomous.merged, none: 'no ticket merged' }] }, + ]], + ['Trusted', [ + { title: 'Agreement', note: 'your answers that went with the recommendation', unit: '%', series: [{ name: 'agreement', v: (f) => learnShare(f.trusted.agreed, f.trusted.answered), n: (f) => f.trusted.answered }] }, + { title: 'Corrections per 100 calls', note: 'calls you corrected, for every hundred the supervisor made', series: [{ name: 'corrections per 100 calls', v: (f) => +((100 * f.trusted.corrections) / Math.max(1, f.trusted.calls)).toFixed(1), n: (f) => f.trusted.calls }] }, + ]], +]; +// a record whose three columns aren't objects (a hand-written line) is left out, not drawn +const learnable = (r) => !!r && !!r.figures && ['informed', 'autonomous', 'trusted'].every((k) => r.figures[k] && typeof r.figures[k] === 'object'); +function learningSection(o) { + const recs = (o.learning || []).filter(learnable); + const T = o.learning_totals || {}; + const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`; + if (recs.length < 3) { + const since = T.since ? fmtDate(Date.parse(T.since)) : 'the start'; + return `
${[['calls', T.calls], ['lessons kept', T.lessons], ['corrections', T.corrections]].map(([k, v]) => `
${esc(`${k} since ${since}`)}${esc(v ?? 0)}
`).join('')}
+

Since ${esc(since)}: ${esc(plural(T.calls ?? 0, 'call'))}, ${esc(plural(T.lessons ?? 0, 'lesson'))} kept, ${esc(plural(T.corrections ?? 0, 'correction'))}. The loop needs a few weeks of calls, answers and lessons before trends mean anything.

`; + } + const xmarks = (o.learning_marks || []).map((m) => ({ at: m.at, label: m.label, href: m.href })); + const span = [Date.parse(recs[0].from), Date.parse(recs[recs.length - 1].from) + 7 * DAY]; + const chart = (c) => { + const series = c.series.map((s, i) => ({ name: s.name, color: i ? 'var(--amber)' : 'var(--series-1)', points: recs.map((r) => { + const v = s.v(r.figures); + const n = s.n ? s.n(r.figures) : null; + const few = n !== null && n < LEARN_MIN; + return [Date.parse(r.from), v, { hollow: few, href: `#learning/${encodeURIComponent(r.id)}`, title: few ? `${s.name}, week ${r.week}: ${n === 0 && s.none ? s.none : `too few to say (n = ${n})`}` : `${s.name}, week ${r.week}: ${v}${c.unit || ''}${n !== null ? ` (n = ${n})` : ''}` }]; + }) })); + return `

${esc(c.title)}

${lineChart(series, { unit: c.unit || '', height: 120, width: 360, span, xmarks })}

${esc(c.note)}

`; + }; + return `
${LEARN_CHARTS.map(([col, charts]) => `

${esc(col)}

${charts.map(chart).join('')}
`).join('')}
+

Weekly, from each week's learning record: open a point for what it counted. A hollow point is too few to say (under ${LEARN_MIN}). Dashed lines are retros and rung changes.

`; +} +/* learning end */ // A set's progress: its meter and forecast, its burn-up, then its tickets as the board. function progressBlock(items) { const now = Date.parse(DATA.progress.now); @@ -3170,15 +3244,34 @@

Its observations

${items.length ? `

`)); + // one hollow point: week 2's signals explained, formed only twice + assert.equal((h.match(/data-hollow/g) || []).length, 1); + assert.match(h, /signals explained, week 2026-W42: too few to say \(n = 2\)/); + for (const r of recs) assert.ok(h.includes(`href="#learning/${encodeURIComponent(r.id)}"`), 'each point opens its week'); + // the rung change marks every chart; one outside the span marks none + assert.equal((h.match(/href="#decision\/d-x"/g) || []).length, 6); + assert.doesNotMatch(h, /#retro\/o-y/); + // questions per merged ticket counts against the merges: a week with none is hollow, and says so + const dry = rec(2, 6); + dry.figures.autonomous.merged = 0; + const hd = learningSection({ learning: [rec(1, 6), dry, rec(3, 6)], learning_totals: totals, learning_marks: [] }); + assert.equal((hd.match(/data-hollow/g) || []).length, 1); + assert.match(hd, /questions per merged ticket, week 2026-W42: no ticket merged/); + // a hand-written record without its three columns is left out, never a crash + const bad = { ...rec(4, 6), figures: { informed: 1 } }; + const hb = learningSection({ learning: [...recs, bad, { ...rec(5, 6), figures: null }], learning_totals: totals, learning_marks: [] }); + assert.equal((hb.match(/]*fill="var\(--series-1, var\(--blue\)\)">p50: 3ms/); + assert.doesNotMatch(plain, / { + const F = fleetFixture('ooda-learning-long'); + const { old, ok } = F; + try { + const hand = handFor(F); + const A = mondayOf(Date.now()) - 14 * DAY; + const w = `${'t'.repeat(56)}-a`; + const d = hand('supervisor', { type: 'decision', class: 'priority-change', what: 'raised the build fix', authority: 'default' }, A + HOUR); + hand('owner', { type: 'decision', op: 'set', id: d, corrected: { reason: 'not that one' } }, A + 2 * HOUR); + // sixty recurring signals, each on its own key from a ticket with a long name (so each id is + // long), each explained by a lesson citing its first observation + const firsts = []; + for (let i = 0; i < 60; i++) { + const os = [0, 1, 2].map((j) => hand(w, { type: 'observation', source: 'repo', subject: `app${i}`, kind: 'note', detail: `slow ${i}.${j}` }, A + 3 * HOUR + j * DAY + i * 1000)); + firsts.push(os[0]); + } + for (let i = 0; i < 6; i++) hand('owner', { type: 'knowledge', kind: 'lesson', statement: `Lesson ${i}`, scope: 'project', status: 'accepted', evidence: firsts.slice(10 * i, 10 * i + 10) }, A + 3 * DAY + i * 1000); + ok(old, ['observe-metrics'], SUP); + const [a] = learnings(F); + assert.equal(a.week, isoWeek(A)); + assert.equal(a.figures.informed.signals_explained, 60); + assert.ok(a.refs.length > 0 && a.refs.length < 60, `trimmed: ${a.refs.length}`); + assert.equal(a.refs[0], d, 'the correction first, and kept'); + const raw = fs.readdirSync(path.join(old, 'docs/devteam/run/log')).flatMap((f) => fs.readFileSync(path.join(old, 'docs/devteam/run/log', f), 'utf8').split('\n')).find((l) => l.includes('"kind":"learning"')); + assert.ok(Buffer.byteLength(raw) <= 4096, `${Buffer.byteLength(raw)} bytes`); + } finally { F.done(); } +}); + +test('a log from long ago back-fills two years at most; a hand line with the key holds no week; the dashboard shows one record a week', () => { + const F = fleetFixture('ooda-learning-old'); + const { old, ok } = F; + try { + const hand = handFor(F); + const cur = mondayOf(Date.now()); + hand('supervisor', { type: 'observation', source: 'repo', subject: 'app', kind: 'note', detail: 'long ago' }, cur - 160 * 7 * DAY); + const last = isoWeek(cur - 7 * DAY); + const prev = isoWeek(cur - 14 * DAY); + // an owner's line with last week's key, and a malformed supervisor one: neither is a record + hand('owner', { type: 'observation', source: 'metrics', subject: 'project', kind: 'learning', week: last, source_key: `learning:${last}`, detail: 'mine' }, cur - 2 * DAY); + ok(old, ['observe-metrics'], SUP); + const rs = learnings(F).filter((r) => r.created_by === 'supervisor'); + assert.equal(rs.length, 104, 'two years, not three'); + assert.equal(rs[0].week, isoWeek(cur - 104 * 7 * DAY)); + assert.ok(rs.some((r) => r.week === last), 'the owner line did not stand in for the week'); + // a second record for a week (two passes at once), and a malformed one: the dashboard shows the earliest, once + hand('supervisor', { type: 'observation', source: 'metrics', detector: 'metrics', subject: 'project', kind: 'learning', week: prev, source_key: `learning:${prev}`, detail: 'again', figures: (({ informed, ...f }) => ({ ...f, informed: { ...informed, lessons_cited: 2 }, later: { n: 1 } }))(rs.find((r) => r.week === prev).figures) }, Date.now() + 1000); + // a count a later kit adds is no error (the spec, §8) + ok(old, ['log-check']); + hand('supervisor', { type: 'observation', source: 'metrics', detector: 'metrics', subject: 'project', kind: 'learning', week: '2020-W01', source_key: 'learning:2020-W01', detail: 'bad', figures: { informed: 1 } }, Date.now() + 2000); + const D = json(ok(old, ['dashboard-data'])); + assert.equal(D.ooda.learning.length, 104); + assert.equal(D.ooda.learning.filter((r) => r.week === prev).length, 1); + assert.equal(D.ooda.learning.find((r) => r.week === prev).id, rs.find((r) => r.week === prev).id, 'the earliest'); + assert.ok(!D.ooda.learning.some((r) => r.week === '2020-W01')); + } finally { F.done(); } +});