Oregami
Repositories/oxedyne/daimond

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})();