oxedyne/daimond/www/js/signals.js
17.1 KiB, 1 run
created by r2519314175:1441, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* ============================================================ |
| 2 | Daimond — the signal index (DaimondSignals) |
| 3 | ============================================================ |
| 4 | |
| 5 | What the Optimiser reads. A continuously-maintained local index |
| 6 | of how this account actually works, kept as counters and rolling |
| 7 | windows rather than as a pile of transcripts to be re-read. |
| 8 | |
| 9 | The order matters and is the whole design: index first, findings |
| 10 | second, a model last and only on what the index flagged. Steps |
| 11 | one to three of that need no model at all, so they are free, |
| 12 | instant, and nothing leaves the device. |
| 13 | |
| 14 | ── The signal nobody instruments ────────────────────────── |
| 15 | |
| 16 | The user's own reaction is the only ground truth an agent app |
| 17 | has about whether a turn was any good, and it arrives constantly, |
| 18 | unprompted, for nothing. A message that swears, shouts, repeats an |
| 19 | instruction, or collapses to three cross words is a NEGATIVE LABEL |
| 20 | on the assistant turn before it. Aggregate those labels by model, |
| 21 | by tool, by context length, by Diamond, and you can answer |
| 22 | questions nobody currently can: which model irritates this person, |
| 23 | which tool wastes their time, when a thread should have folded. |
| 24 | |
| 25 | Detection is cheap. Rate of strong language, capitals, runs of |
| 26 | `?!`, correction phrases, and a message far shorter than this |
| 27 | person's own norm -- counted here, against THIS user's baseline, |
| 28 | never sent anywhere. |
| 29 | |
| 30 | ── The rule this module is written around ───────────────── |
| 31 | |
| 32 | **The detection channel is not the presentation channel.** |
| 33 | |
| 34 | This module may read irritation. Nothing built on it may ever |
| 35 | MENTION it. "You seem frustrated" is the fastest possible way to |
| 36 | get an Optimiser deleted, and it is also useless: the reader knows. |
| 37 | What surfaces is the defect the irritation pointed AT -- |
| 38 | "six requests to open a page opened a browser window instead" -- |
| 39 | with the affect used only to find it and then dropped. |
| 40 | `digest()` is written to that rule and `verify_signals` asserts it. |
| 41 | |
| 42 | So: no score, no gauge, no mood. If a number here ever reaches a |
| 43 | surface a person reads, this module has been misused. |
| 44 | |
| 45 | ── What is stored, and where ────────────────────────────── |
| 46 | |
| 47 | Counters in `localStorage` under `daimond-signals`, per-account |
| 48 | like every other `daimond-*` key. NO MESSAGE TEXT is kept -- a |
| 49 | message is scored, the counters move, and the text is dropped. The |
| 50 | index cannot leak what it does not hold. |
| 51 | |
| 52 | A digest is written into the workspace at `system/usage/digest.md`, |
| 53 | where the Optimiser reads it with the file tools it already has. |
| 54 | That is the answer to notes2 #51: the Optimiser cannot be given a |
| 55 | SCOPE over the ledger or over chats, because neither is a folder -- |
| 56 | so what it needs is put into a folder instead. |
| 57 | |
| 58 | Attaches a single global, `window.DaimondSignals`. Also exported |
| 59 | for Node, so the scoring can be tested without a browser. |
| 60 | ============================================================ */ |
| 61 | (function () { |
| 62 | 'use strict'; |
| 63 | |
| 64 | var KEY = 'daimond-signals'; |
| 65 | var DAY_MS = 24 * 60 * 60 * 1000; |
| 66 | var RETAIN_DAYS = 60; |
| 67 | // A run of days kept for rates; enough to see a change of habit and not so |
| 68 | // much that the record becomes a history of the user's year. |
| 69 | var MAX_DAYS = RETAIN_DAYS; |
| 70 | |
| 71 | // ── Scoring ──────────────────────────────────────────────── |
| 72 | // |
| 73 | // Composite, and deliberately not a word list alone: some people swear |
| 74 | // cheerfully in every message and some never do, so a raw count says more |
| 75 | // about the person than about the turn. What is measured is a DEPARTURE |
| 76 | // from this user's own norm, and strong language is one of five inputs. |
| 77 | |
| 78 | // Kept short and unambiguous. A longer list buys nothing -- this is one |
| 79 | // input among five and the composite carries the decision. |
| 80 | var STRONG = /\b(fuck\w*|shit\w*|bloody|bollocks|christ|wtf|damn\w*)\b/i; |
| 81 | // What a person writes when the last answer missed: they say so. |
| 82 | var CORRECTION = /\b(no,? i (said|asked|told)|that('s| is) not what|as i (already )?(said|told)|why would you|i already|again[,.!?]|still (wrong|broken|not))\b/i; |
| 83 | // A run of marks is shouting in punctuation. |
| 84 | var RUNS = /[!?]{2,}/; |
| 85 | |
| 86 | /// Score one user message for the signs that the turn before it missed. |
| 87 | /// |
| 88 | /// Returns the count of independent signals present, 0 to 5. Nothing here |
| 89 | /// is a judgement about the person: each is a way of saying "that was not |
| 90 | /// it" and the count is only how many of them arrived at once. |
| 91 | /// |
| 92 | /// # Arguments |
| 93 | /// * `text` - What the user typed. |
| 94 | /// * `norm` - Their usual message length, for the terseness signal. |
| 95 | function score(text, norm) { |
| 96 | var s = String(text == null ? '' : text); |
| 97 | var trimmed = s.trim(); |
| 98 | if (!trimmed) return 0; |
| 99 | var n = 0; |
| 100 | if (STRONG.test(trimmed)) n++; |
| 101 | if (CORRECTION.test(trimmed)) n++; |
| 102 | if (RUNS.test(trimmed)) n++; |
| 103 | // Capitals, but only where there is enough text for it to be a choice. |
| 104 | // A three-letter "NO" is caught by terseness below. |
| 105 | var letters = trimmed.replace(/[^A-Za-z]/g, ''); |
| 106 | if (letters.length >= 12) { |
| 107 | var caps = trimmed.replace(/[^A-Z]/g, '').length; |
| 108 | if (caps / letters.length > 0.6) n++; |
| 109 | } |
| 110 | // Terseness, against this user's own norm rather than a fixed number: |
| 111 | // somebody whose messages run to a paragraph saying "no" is a signal; |
| 112 | // somebody who always writes six words is not. |
| 113 | if (norm && norm >= 40 && trimmed.length <= Math.min(20, norm * 0.15)) n++; |
| 114 | return n; |
| 115 | } |
| 116 | |
| 117 | /// Two or more independent signals. One alone is noise -- a person may |
| 118 | /// swear at the weather, or write "again." meaning "once more please". |
| 119 | function missed(text, norm) { return score(text, norm) >= 2; } |
| 120 | |
| 121 | // ── The index ────────────────────────────────────────────── |
| 122 | |
| 123 | function blank() { |
| 124 | return { |
| 125 | v: 1, |
| 126 | // Rolling message-length mean, so terseness has a baseline. Kept as |
| 127 | // a sum and a count rather than as a mean, so it can be widened |
| 128 | // without rescaling anything. |
| 129 | len: { sum: 0, n: 0 }, |
| 130 | // Per Diamond: turns, spend, the last time it was worked, how many |
| 131 | // turns were followed by a sign that they missed, and which tools |
| 132 | // were called and how many refused. |
| 133 | diamonds: {}, |
| 134 | // Per model, the same question: does this one miss more often? |
| 135 | models: {}, |
| 136 | // Per tool name: calls, failures, and misses that FOLLOWED a turn |
| 137 | // that used it. The last is the one nothing else can see. |
| 138 | tools: {}, |
| 139 | // Per day: turns and misses, so a change of habit is visible as a |
| 140 | // change rather than as a total. |
| 141 | days: {}, |
| 142 | // Intents seen more than once, as HASHES of the normalised text -- |
| 143 | // never the text. Enough to say "you have asked for this eleven |
| 144 | // times", never enough to reconstruct what was asked. |
| 145 | intents: {}, |
| 146 | }; |
| 147 | } |
| 148 | |
| 149 | function load() { |
| 150 | try { |
| 151 | var raw = localStorage.getItem(KEY); |
| 152 | if (!raw) return blank(); |
| 153 | var o = JSON.parse(raw); |
| 154 | return (o && o.v === 1) ? o : blank(); |
| 155 | } catch (e) { return blank(); } |
| 156 | } |
| 157 | function save(ix) { |
| 158 | try { localStorage.setItem(KEY, JSON.stringify(ix)); } |
| 159 | catch (e) { /* full or private: the index is best-effort by design */ } |
| 160 | } |
| 161 | function day(ts) { return new Date(ts || Date.now()).toISOString().slice(0, 10); } |
| 162 | |
| 163 | function bump(map, key, field, by) { |
| 164 | if (!key) return; |
| 165 | var row = map[key] || (map[key] = {}); |
| 166 | row[field] = (row[field] || 0) + (by == null ? 1 : by); |
| 167 | } |
| 168 | |
| 169 | /// A stable, non-reversible handle for an intent. |
| 170 | /// |
| 171 | /// The text is lowercased, stripped of punctuation and of the twenty |
| 172 | /// commonest English filler words, then hashed. Two ways of asking the same |
| 173 | /// thing land together often enough to be useful, and the hash means the |
| 174 | /// index can say "eleven times" while holding none of the eleven. |
| 175 | var FILLER = /\b(the|a|an|and|or|of|to|in|is|it|for|on|with|that|this|please|can|you|i|me|my)\b/g; |
| 176 | function intentHash(text) { |
| 177 | var s = String(text || '').toLowerCase() |
| 178 | .replace(/[^a-z0-9\s]/g, ' ') |
| 179 | .replace(FILLER, ' ') |
| 180 | .replace(/\s+/g, ' ') |
| 181 | .trim(); |
| 182 | if (s.length < 12) return ''; // too short to mean anything twice |
| 183 | var h = 5381; |
| 184 | for (var i = 0; i < s.length; i++) { h = ((h * 33) ^ s.charCodeAt(i)) >>> 0; } |
| 185 | return h.toString(36); |
| 186 | } |
| 187 | |
| 188 | /// Note that the user said something to a Diamond. |
| 189 | /// |
| 190 | /// This is where the label is made: the message is scored, the counters for |
| 191 | /// whatever ran BEFORE it move, and the text is dropped. |
| 192 | /// |
| 193 | /// # Arguments |
| 194 | /// * `ev` - `{ diamondId, text, prevModel, prevTools }`, where the two |
| 195 | /// `prev` fields describe the turn this message is reacting to. |
| 196 | function noteUserMessage(ev) { |
| 197 | ev = ev || {}; |
| 198 | var ix = load(); |
| 199 | var text = String(ev.text || ''); |
| 200 | var norm = ix.len.n ? (ix.len.sum / ix.len.n) : 0; |
| 201 | var bad = missed(text, norm); |
| 202 | |
| 203 | ix.len.sum += text.trim().length; |
| 204 | ix.len.n += 1; |
| 205 | |
| 206 | var d = day(); |
| 207 | var dayRow = ix.days[d] || (ix.days[d] = { turns: 0, missed: 0 }); |
| 208 | dayRow.turns++; |
| 209 | if (bad) dayRow.missed++; |
| 210 | |
| 211 | if (bad) { |
| 212 | bump(ix.diamonds, ev.diamondId, 'missed'); |
| 213 | bump(ix.models, ev.prevModel, 'missed'); |
| 214 | (ev.prevTools || []).forEach(function (name) { |
| 215 | bump(ix.tools, name, 'missedAfter'); |
| 216 | }); |
| 217 | } |
| 218 | |
| 219 | // A repeated ask is a missing skill or a missing line in a crystal. |
| 220 | var h = intentHash(text); |
| 221 | if (h) { |
| 222 | var row = ix.intents[h] || (ix.intents[h] = { n: 0, words: 0, last: 0 }); |
| 223 | row.n++; |
| 224 | row.words = Math.min(12, text.trim().split(/\s+/).length); |
| 225 | row.last = Date.now(); |
| 226 | } |
| 227 | |
| 228 | prune(ix); |
| 229 | save(ix); |
| 230 | return bad; |
| 231 | } |
| 232 | |
| 233 | /// Note a completed turn: what it cost, what it ran on, what it called. |
| 234 | function noteTurn(ev) { |
| 235 | ev = ev || {}; |
| 236 | var ix = load(); |
| 237 | bump(ix.diamonds, ev.diamondId, 'turns'); |
| 238 | bump(ix.diamonds, ev.diamondId, 'usd', ev.usd || 0); |
| 239 | if (ev.diamondId) { |
| 240 | var row = ix.diamonds[ev.diamondId]; |
| 241 | row.last = Math.max(row.last || 0, ev.ts || Date.now()); |
| 242 | } |
| 243 | bump(ix.models, ev.model, 'turns'); |
| 244 | bump(ix.models, ev.model, 'usd', ev.usd || 0); |
| 245 | save(ix); |
| 246 | } |
| 247 | |
| 248 | /// Note one tool call and whether it refused. |
| 249 | function noteTool(name, ok) { |
| 250 | if (!name) return; |
| 251 | var ix = load(); |
| 252 | bump(ix.tools, name, 'calls'); |
| 253 | if (!ok) bump(ix.tools, name, 'failed'); |
| 254 | save(ix); |
| 255 | } |
| 256 | |
| 257 | /// Drop what is older than the retention window, and the intents that never |
| 258 | /// came back. An index that only grows becomes a history rather than a |
| 259 | /// picture of how things are now. |
| 260 | function prune(ix) { |
| 261 | var days = Object.keys(ix.days).sort(); |
| 262 | while (days.length > MAX_DAYS) { delete ix.days[days.shift()]; } |
| 263 | var cutoff = Date.now() - RETAIN_DAYS * DAY_MS; |
| 264 | Object.keys(ix.intents).forEach(function (h) { |
| 265 | var r = ix.intents[h]; |
| 266 | if (r.n < 2 && r.last < cutoff) delete ix.intents[h]; |
| 267 | }); |
| 268 | return ix; |
| 269 | } |
| 270 | |
| 271 | // ── Findings ─────────────────────────────────────────────── |
| 272 | // |
| 273 | // Each is a defect with a number behind it and something the reader could |
| 274 | // DO. A finding with no action is a complaint, and a finding without its |
| 275 | // evidence is astrology, so each carries both. |
| 276 | // |
| 277 | // None of them mentions the affect signal. Where irritation is what found |
| 278 | // the thing, the finding names the thing. |
| 279 | |
| 280 | /// What the index can say without a model, given the Diamonds it is handed. |
| 281 | /// |
| 282 | /// # Arguments |
| 283 | /// * `diamonds` - `[{ id, name }]`, so a finding can name a Diamond rather |
| 284 | /// than an id nobody recognises. |
| 285 | /// * `now` - Milliseconds, injectable so this is testable. |
| 286 | function findings(diamonds, now) { |
| 287 | var ix = load(); |
| 288 | now = now || Date.now(); |
| 289 | var byId = {}; |
| 290 | (diamonds || []).forEach(function (d) { byId[d.id] = d.name || d.id; }); |
| 291 | var out = []; |
| 292 | |
| 293 | // A tool that refuses often is a broken tool, whatever the user thinks |
| 294 | // they did wrong. |
| 295 | Object.keys(ix.tools).forEach(function (name) { |
| 296 | var r = ix.tools[name]; |
| 297 | if ((r.calls || 0) >= 5 && (r.failed || 0) / r.calls > 0.25) { |
| 298 | out.push({ |
| 299 | kind: 'tool-failing', |
| 300 | what: name + ' refused ' + r.failed + ' of ' + r.calls + ' calls.', |
| 301 | do_: 'Look at what it is being asked for before asking it again.', |
| 302 | }); |
| 303 | } |
| 304 | // The one nothing else can see: the tool whose turns keep being |
| 305 | // followed by the user saying it missed. |
| 306 | if ((r.calls || 0) >= 5 && (r.missedAfter || 0) >= 3) { |
| 307 | out.push({ |
| 308 | kind: 'tool-misses', |
| 309 | what: r.missedAfter + ' of your turns using ' + name |
| 310 | + ' needed correcting afterwards.', |
| 311 | do_: 'It answers, but with the wrong thing. Check what it returns.', |
| 312 | }); |
| 313 | } |
| 314 | }); |
| 315 | |
| 316 | // A model that costs more and lands less often. |
| 317 | Object.keys(ix.models).forEach(function (m) { |
| 318 | var r = ix.models[m]; |
| 319 | if ((r.turns || 0) >= 10 && (r.missed || 0) / r.turns > 0.3) { |
| 320 | out.push({ |
| 321 | kind: 'model-misses', |
| 322 | what: Math.round(100 * r.missed / r.turns) + '% of turns on ' + m |
| 323 | + ' needed correcting (' + r.missed + ' of ' + r.turns + ').', |
| 324 | do_: 'Try a different model on whatever uses this one most.', |
| 325 | }); |
| 326 | } |
| 327 | }); |
| 328 | |
| 329 | // Dormant, and costing nothing but sitting in the way. |
| 330 | Object.keys(ix.diamonds).forEach(function (id) { |
| 331 | var r = ix.diamonds[id]; |
| 332 | var idle = r.last ? (now - r.last) / DAY_MS : null; |
| 333 | if (idle !== null && idle > 45 && (r.turns || 0) > 0) { |
| 334 | out.push({ |
| 335 | kind: 'dormant', |
| 336 | what: (byId[id] || id) + ' has not been worked for ' |
| 337 | + Math.round(idle) + ' days.', |
| 338 | do_: 'Fold it into another Diamond, or delete it.', |
| 339 | }); |
| 340 | } |
| 341 | }); |
| 342 | |
| 343 | // The repeated ask: a missing skill, or a line missing from a crystal. |
| 344 | Object.keys(ix.intents).forEach(function (h) { |
| 345 | var r = ix.intents[h]; |
| 346 | if ((r.n || 0) >= 5) { |
| 347 | out.push({ |
| 348 | kind: 'repeated', |
| 349 | what: 'You have asked for the same thing ' + r.n + ' times.', |
| 350 | do_: 'Put it in the crystal, or make it a skill.', |
| 351 | }); |
| 352 | } |
| 353 | }); |
| 354 | |
| 355 | return out; |
| 356 | } |
| 357 | |
| 358 | /// The digest the Optimiser reads, as markdown. |
| 359 | /// |
| 360 | /// Written to be read by a model AND by a person, because the same file is |
| 361 | /// the evidence behind anything the Optimiser proposes. It states what it |
| 362 | /// does not know: an index that reports only what it has looks complete. |
| 363 | function digest(diamonds, now) { |
| 364 | var ix = load(); |
| 365 | now = now || Date.now(); |
| 366 | var L = []; |
| 367 | L.push('# How this account is being used'); |
| 368 | L.push(''); |
| 369 | L.push('Counted on this device. No message text is kept and none of this has'); |
| 370 | L.push('been sent anywhere. Numbers are from the last ' + RETAIN_DAYS + ' days.'); |
| 371 | L.push(''); |
| 372 | |
| 373 | var byId = {}; |
| 374 | (diamonds || []).forEach(function (d) { byId[d.id] = d.name || d.id; }); |
| 375 | |
| 376 | L.push('## Diamonds'); |
| 377 | L.push(''); |
| 378 | var ids = Object.keys(ix.diamonds); |
| 379 | if (!ids.length) { |
| 380 | L.push('Nothing recorded yet.'); |
| 381 | } else { |
| 382 | L.push('| Diamond | turns | spend (USD) | last worked |'); |
| 383 | L.push('|---|---|---|---|'); |
| 384 | ids.sort(function (a, b) { |
| 385 | return (ix.diamonds[b].turns || 0) - (ix.diamonds[a].turns || 0); |
| 386 | }).forEach(function (id) { |
| 387 | var r = ix.diamonds[id]; |
| 388 | L.push('| ' + (byId[id] || id) + ' | ' + (r.turns || 0) + ' | ' |
| 389 | + (r.usd || 0).toFixed(4) + ' | ' |
| 390 | + (r.last ? Math.round((now - r.last) / DAY_MS) + 'd ago' : 'never') + ' |'); |
| 391 | }); |
| 392 | } |
| 393 | L.push(''); |
| 394 | |
| 395 | L.push('## Models'); |
| 396 | L.push(''); |
| 397 | var ms = Object.keys(ix.models); |
| 398 | if (!ms.length) { L.push('Nothing recorded yet.'); } |
| 399 | else { |
| 400 | L.push('| Model | turns | spend (USD) |'); |
| 401 | L.push('|---|---|---|'); |
| 402 | ms.forEach(function (m) { |
| 403 | var r = ix.models[m]; |
| 404 | L.push('| ' + m + ' | ' + (r.turns || 0) + ' | ' + (r.usd || 0).toFixed(4) + ' |'); |
| 405 | }); |
| 406 | } |
| 407 | L.push(''); |
| 408 | |
| 409 | L.push('## Tools'); |
| 410 | L.push(''); |
| 411 | var ts = Object.keys(ix.tools); |
| 412 | if (!ts.length) { L.push('Nothing recorded yet.'); } |
| 413 | else { |
| 414 | L.push('| Tool | calls | refused |'); |
| 415 | L.push('|---|---|---|'); |
| 416 | ts.forEach(function (n) { |
| 417 | var r = ix.tools[n]; |
| 418 | L.push('| ' + n + ' | ' + (r.calls || 0) + ' | ' + (r.failed || 0) + ' |'); |
| 419 | }); |
| 420 | } |
| 421 | L.push(''); |
| 422 | |
| 423 | L.push('## What stands out'); |
| 424 | L.push(''); |
| 425 | var f = findings(diamonds, now); |
| 426 | if (!f.length) { |
| 427 | // Silence is a valid week. An Optimiser that always finds three |
| 428 | // things is inventing them. |
| 429 | L.push('Nothing. This section is empty because nothing stood out.'); |
| 430 | } else { |
| 431 | f.forEach(function (x) { L.push('- **' + x.what + '** ' + x.do_); }); |
| 432 | } |
| 433 | L.push(''); |
| 434 | L.push('## What this does not know'); |
| 435 | L.push(''); |
| 436 | L.push('- What any conversation was about. Only that turns happened.'); |
| 437 | L.push('- Whether a turn achieved anything, except where it had to be redone.'); |
| 438 | L.push('- Anything at all about work done on another device.'); |
| 439 | return L.join('\n'); |
| 440 | } |
| 441 | |
| 442 | function snapshot() { return load(); } |
| 443 | function reset() { try { localStorage.removeItem(KEY); } catch (e) {} } |
| 444 | |
| 445 | var api = { |
| 446 | // Scoring, exported so it can be tested and so nothing else re-implements it. |
| 447 | score: score, |
| 448 | missed: missed, |
| 449 | intentHash: intentHash, |
| 450 | // Recording. |
| 451 | noteUserMessage: noteUserMessage, |
| 452 | noteTurn: noteTurn, |
| 453 | noteTool: noteTool, |
| 454 | // Reading. |
| 455 | findings: findings, |
| 456 | digest: digest, |
| 457 | snapshot: snapshot, |
| 458 | reset: reset, |
| 459 | RETAIN_DAYS: RETAIN_DAYS, |
| 460 | }; |
| 461 | |
| 462 | if (typeof window !== 'undefined') window.DaimondSignals = api; |
| 463 | if (typeof module !== 'undefined' && module.exports) module.exports = api; |
| 464 | })(); |