mixdog 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +117 -434
  2. package/package.json +18 -17
  3. package/scripts/gate-local.mjs +93 -0
  4. package/scripts/lib/gate-scheduler.mjs +24 -0
  5. package/scripts/lib/run-node-tests.mjs +5 -1
  6. package/scripts/release-paths.mjs +22 -1
  7. package/scripts/test-direct.mjs +2 -1
  8. package/scripts/test.mjs +3 -1
  9. package/src/defaults/agents.json +0 -12
  10. package/src/defaults/mixdog-config.template.json +1 -2
  11. package/src/defaults/skills/docx/SKILL.md +7 -4
  12. package/src/defaults/skills/docx/references/html.md +46 -0
  13. package/src/defaults/skills/docx/references/native-authoring.md +18 -4
  14. package/src/defaults/skills/pdf/SKILL.md +10 -2
  15. package/src/defaults/skills/pdf/references/html.md +64 -0
  16. package/src/defaults/skills/pptx/references/html.md +1 -1
  17. package/src/defaults/skills/xlsx/SKILL.md +7 -3
  18. package/src/defaults/skills/xlsx/references/html.md +40 -0
  19. package/src/defaults/skills/xlsx/references/report-design.md +21 -4
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -3
  22. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +6 -5
  24. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +6 -3
  25. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +6 -2
  26. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +50 -9
  27. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +28 -1
  28. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +4 -1
  29. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +34 -8
  30. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +10 -2
  31. package/src/runtime/agent/orchestrator/session/transcript-restore/agent-envelope.mjs +1 -1
  32. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +1 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +3 -1
  34. package/src/runtime/attachments/pdf-extract.mjs +7 -3
  35. package/src/runtime/computer-bridge/client.mjs +1 -1
  36. package/src/runtime/memory/index.mjs +4 -15
  37. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  38. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +0 -2
  39. package/src/runtime/memory/lib/cycle-scheduler/backlog-probe.mjs +3 -11
  40. package/src/runtime/memory/lib/cycle-scheduler/health-ledger.mjs +1 -3
  41. package/src/runtime/memory/lib/cycle-scheduler/scheduled-enqueue.mjs +1 -11
  42. package/src/runtime/memory/lib/cycle-scheduler/tick-loop.mjs +0 -2
  43. package/src/runtime/memory/lib/cycle-scheduler.mjs +4 -13
  44. package/src/runtime/memory/lib/cycle-signatures.mjs +0 -6
  45. package/src/runtime/memory/lib/cycle1/cycle1-rows.mjs +1 -1
  46. package/src/runtime/memory/lib/cycle1/cycle1-window.mjs +2 -2
  47. package/src/runtime/memory/lib/embedding-reindex.mjs +1 -1
  48. package/src/runtime/memory/lib/http-router/lifecycle-routes.mjs +0 -1
  49. package/src/runtime/memory/lib/memory-action-handlers/cycle-actions.mjs +7 -52
  50. package/src/runtime/memory/lib/memory-action-handlers/cycle-backfill-action.mjs +6 -22
  51. package/src/runtime/memory/lib/memory-action-handlers/cycle-rebuild-action.mjs +10 -17
  52. package/src/runtime/memory/lib/memory-action-handlers/maintenance-actions.mjs +1 -3
  53. package/src/runtime/memory/lib/memory-action-handlers/manage-actions.mjs +2 -4
  54. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -4
  55. package/src/runtime/memory/lib/memory-cycle-requests.mjs +1 -1
  56. package/src/runtime/memory/lib/{memory-cycle2-shared.mjs → memory-cycle-shared.mjs} +14 -16
  57. package/src/runtime/memory/lib/memory-cycle.mjs +3 -2
  58. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  59. package/src/runtime/memory/lib/memory-embed.mjs +14 -1
  60. package/src/runtime/memory/lib/memory-ops-policy.mjs +8 -11
  61. package/src/runtime/memory/lib/memory-schema/entries.mjs +0 -1
  62. package/src/runtime/memory/lib/memory.mjs +2 -6
  63. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  64. package/src/runtime/memory/lib/query-handlers.mjs +1 -1
  65. package/src/runtime/memory/lib/query-hybrid-search.mjs +1 -1
  66. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +0 -6
  67. package/src/runtime/memory/lib/recall-format.mjs +1 -1
  68. package/src/runtime/office/authoring/docx-html-build.mjs +375 -0
  69. package/src/runtime/office/authoring/docx-html-measure.mjs +561 -0
  70. package/src/runtime/office/authoring/docx-html-runner.mjs +102 -0
  71. package/src/runtime/office/authoring/html-browser.mjs +61 -0
  72. package/src/runtime/office/authoring/html-document-author.mjs +181 -0
  73. package/src/runtime/office/authoring/html-source-drift.mjs +36 -0
  74. package/src/runtime/office/authoring/pdf-author-action.mjs +266 -0
  75. package/src/runtime/office/authoring/pdf-html-charts.mjs +379 -0
  76. package/src/runtime/office/authoring/pdf-html-runner.mjs +317 -0
  77. package/src/runtime/office/authoring/pptx-author-action.mjs +44 -22
  78. package/src/runtime/office/authoring/pptx-author-session.mjs +12 -6
  79. package/src/runtime/office/authoring/pptx-html-build.mjs +23 -4
  80. package/src/runtime/office/authoring/pptx-html-measure.mjs +38 -44
  81. package/src/runtime/office/authoring/pptx-html-runner.mjs +18 -3
  82. package/src/runtime/office/authoring/pptx-receipt.mjs +39 -31
  83. package/src/runtime/office/authoring/pptx-review-artifacts.mjs +9 -1
  84. package/src/runtime/office/authoring/pptx-script-runner.mjs +6 -2
  85. package/src/runtime/office/authoring/xlsx-html-build.mjs +365 -0
  86. package/src/runtime/office/authoring/xlsx-html-measure.mjs +257 -0
  87. package/src/runtime/office/authoring/xlsx-html-runner.mjs +139 -0
  88. package/src/runtime/office/capabilities-catalog.mjs +8 -0
  89. package/src/runtime/office/capabilities-signatures.mjs +18 -6
  90. package/src/runtime/office/capabilities.mjs +26 -1
  91. package/src/runtime/office/com/office-com-host.ps1 +133 -17
  92. package/src/runtime/office/core/office-actions-batch.mjs +16 -1
  93. package/src/runtime/office/core/office-actions-inspect.mjs +3 -2
  94. package/src/runtime/office/core/office-actions-lifecycle.mjs +6 -2
  95. package/src/runtime/office/core/office-actions-render.mjs +4 -1
  96. package/src/runtime/office/core/office-finalize/review-stage.mjs +15 -2
  97. package/src/runtime/office/core/office-qa/design-review-stage.mjs +9 -0
  98. package/src/runtime/office/core/office-session-dispatch.mjs +3 -0
  99. package/src/runtime/office/core/office-sessionless-actions.mjs +7 -2
  100. package/src/runtime/office/core/office-sessions.mjs +19 -2
  101. package/src/runtime/office/pdf/pdf-writer.mjs +1 -1
  102. package/src/runtime/office/portable/docx-formatting.mjs +21 -0
  103. package/src/runtime/office/portable/ooxml-validator.mjs +22 -1
  104. package/src/runtime/office/portable/portable-chart.mjs +10 -2
  105. package/src/runtime/office/portable/portable-docx-edits.mjs +12 -6
  106. package/src/runtime/office/portable/portable-docx-operations.mjs +75 -1
  107. package/src/runtime/office/portable/portable-docx-xml.mjs +49 -6
  108. package/src/runtime/office/portable/portable-docx.mjs +2 -0
  109. package/src/runtime/office/portable/portable-snapshot-docx.mjs +2 -0
  110. package/src/runtime/office/portable/portable-validation.mjs +6 -3
  111. package/src/runtime/office/portable/portable-xlsx-cell-values.mjs +87 -0
  112. package/src/runtime/office/portable/portable-xlsx-charts.mjs +55 -13
  113. package/src/runtime/office/portable/portable-xlsx-sheet-edits.mjs +1 -1
  114. package/src/runtime/office/portable/xlsx-contract.mjs +82 -3
  115. package/src/runtime/office/quality/assurance-checklist.mjs +2 -2
  116. package/src/runtime/office/quality/assurance-rendered.mjs +27 -1
  117. package/src/runtime/office/quality/assurance-structure-docx.mjs +44 -0
  118. package/src/runtime/office/quality/assurance-structure-pptx.mjs +24 -6
  119. package/src/runtime/office/quality/assurance-structure-xlsx.mjs +50 -1
  120. package/src/runtime/office/quality/design-review-critique.mjs +3 -2
  121. package/src/runtime/office/quality/document-brief.mjs +44 -0
  122. package/src/runtime/office/quality/inline-audit.mjs +7 -2
  123. package/src/runtime/office/quality/quality-pipeline.mjs +9 -1
  124. package/src/runtime/office/tool-defs.mjs +2 -2
  125. package/src/runtime/shared/llm/quota-value-estimate.mjs +317 -0
  126. package/src/runtime/shared/llm/usage-ledger-quota.mjs +266 -63
  127. package/src/runtime/shared/llm/usage-ledger-rollup.mjs +35 -0
  128. package/src/runtime/shared/llm/usage-ledger-worker.mjs +2 -1
  129. package/src/runtime/shared/llm/usage-ledger.mjs +10 -1
  130. package/src/runtime/shared/provider-accounts.mjs +11 -1
  131. package/src/runtime/shared/statusline/statusline-segments.mjs +4 -4
  132. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  133. package/src/runtime/shared/wait-until.test-support.mjs +33 -0
  134. package/src/session-runtime/boot/apis.mjs +1 -1
  135. package/src/session-runtime/services/channel-admin.mjs +4 -3
  136. package/src/session-runtime/services/usage-stats-model.mjs +22 -0
  137. package/src/session-runtime/setup-tool/settings-contract.mjs +6 -1
  138. package/src/session-runtime/turn/session-ops.mjs +15 -9
  139. package/src/session-runtime/usage-stats-api.mjs +34 -2
  140. package/src/standalone/agent-dispatch-broker.mjs +1 -1
  141. package/src/standalone/local-session-runtime.mjs +488 -0
  142. package/src/standalone/session-protocol.mjs +1 -0
  143. package/src/standalone/session-runtime-inline-host.mjs +1 -1
  144. package/src/tui/hooks/useSession.mjs +1 -1
  145. package/src/tui/session/session-api/commands.mjs +1 -1
  146. package/src/tui/session/session-api/integrations.mjs +3 -0
  147. package/src/tui/session/session-flow/auto-clear.mjs +6 -7
  148. package/src/tui/session/tool-card-results.mjs +1 -1
  149. package/src/tui/session/turn.mjs +5 -0
  150. package/src/tui/session-local.mjs +3 -488
  151. package/src/tui/session.mjs +1 -1
  152. package/src/ui/statusline-agents.mjs +0 -1
  153. package/src/rules/agent/41-cycle2-agent.md +0 -24
  154. package/src/runtime/memory/lib/cycle-scheduler/cycle2-runs.mjs +0 -120
  155. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +0 -110
  156. package/src/runtime/memory/lib/memory-cycle2-quarantine.mjs +0 -99
  157. package/src/runtime/memory/lib/memory-cycle2-review.mjs +0 -240
  158. package/src/runtime/memory/lib/memory-cycle2.mjs +0 -191
@@ -0,0 +1,44 @@
1
+ // A Word document or workbook held to its brief, as an authored deck is: every figure its words show has a fact with
2
+ // a source, and a fact cites where a reader can open it. The brief is the deck's (`design.brief`, key: value lines);
3
+ // the readings are the deck's own (pptx-brief.mjs), over the document's text instead of its slides.
4
+ import { parseAuthoringBrief, reviewFactCoverage, reviewSourceGrounding } from '../authoring/pptx-brief.mjs';
5
+ import { htmlBriefScript } from '../authoring/pptx-html-runner.mjs';
6
+
7
+ /** The brief a docx/xlsx call names in design.brief (the HTML BRIEF's key: value lines), or null. */
8
+ export function documentBrief(text) {
9
+ const source = String(text ?? '').trim();
10
+ if (!source) return null;
11
+ const brief = parseAuthoringBrief(htmlBriefScript(`<!-- BRIEF\n${source}\n-->`));
12
+ return brief.present ? brief : null;
13
+ }
14
+
15
+ // The document read as the deck reader reads slides: one entry per page-like unit, its text as shapes. A Word body
16
+ // is one unit (paragraphs and table cells); a workbook is one per sheet, and only its words — a cell holding a number
17
+ // is the data a chart or table shows, not a figure quoted in prose, and the facts line cites where those came from.
18
+ function textUnits(format, document) {
19
+ if (format === 'docx') {
20
+ const paragraphs = (document?.paragraphs || []).map((paragraph) => ({ text: paragraph.text }));
21
+ const cells = (document?.tables || []).flatMap((table) =>
22
+ (table.rows || []).flatMap((row) => (row.cells || []).map((cell) => ({ text: cell?.text })))
23
+ );
24
+ return [{ index: 1, path: '/body', shapes: [...paragraphs, ...cells] }];
25
+ }
26
+ return (document?.sheets || []).map((sheet, index) => ({
27
+ index: index + 1,
28
+ path: `/sheet[${sheet.name}]`,
29
+ shapes: (sheet.cells || []).filter((cell) => typeof cell.value === 'string').map((cell) => ({ text: cell.value })),
30
+ }));
31
+ }
32
+
33
+ /** The brief's readings on a Word document or workbook: unfounded figures, a missing facts line, loose sources. */
34
+ export function documentBriefIssues(format, document, brief) {
35
+ if (!brief?.present || !['docx', 'xlsx'].includes(format)) return [];
36
+ const units = textUnits(format, document);
37
+ const paths = new Map(units.map((unit) => [`/slide[${unit.index}]`, unit.path]));
38
+ return [
39
+ ...reviewFactCoverage({ slides: units }, brief).map((entry) =>
40
+ paths.has(entry.path) ? { ...entry, path: paths.get(entry.path) } : entry
41
+ ),
42
+ ...reviewSourceGrounding(brief),
43
+ ];
44
+ }
@@ -134,7 +134,12 @@ export function recordInlineAuditRound(session, audit) {
134
134
 
135
135
  // A mutation that landed is never failed by its audit: an inspection error is
136
136
  // reported as unavailable so the author falls back to qa.
137
- export async function inlineOfficeAudit(session, { operations = [] } = {}) {
137
+ /**
138
+ * @param {{ operations?: object[], exclude?: string[] }} options
139
+ * exclude: finding codes that do not apply to what the reader receives (a PDF the browser printed is not
140
+ * held to PowerPoint's text metrics)
141
+ */
142
+ export async function inlineOfficeAudit(session, { operations = [], exclude = [] } = {}) {
138
143
  if (!session || !OOXML_FORMATS.has(session.format)) return null;
139
144
  let measured;
140
145
  try {
@@ -151,7 +156,7 @@ export async function inlineOfficeAudit(session, { operations = [] } = {}) {
151
156
  // failed its first audit with nothing the author could change. qa and issues still report it as found.
152
157
  const pending = (issue) => (issue?.code === 'formula_cache_missing' ? { ...issue, severity: 'info' } : issue);
153
158
  const touched = touchedLocations(session.format, operations);
154
- let list = (measured.issues || []).map(pending);
159
+ let list = (measured.issues || []).map(pending).filter((issue) => !exclude.includes(issue?.code));
155
160
  // A deck opened for editing — a template, a colleague's deck — is judged on the slides this batch edited: its
156
161
  // other pages are kept as they are, and a defect they already carried is not this edit's to fix. A structural
157
162
  // change renumbers the pages, so it reads the whole deck; so does an authored deck, every page of which is the
@@ -92,6 +92,8 @@ export function isAdvisoryOfficeIssue(issue) {
92
92
  const POLISH_GUIDANCE = Object.freeze({
93
93
  blank_page: 'Remove the accidental page or rebalance preceding content so the page has a clear purpose.',
94
94
  sparse_page: 'Rebalance sections, tables, and page breaks instead of padding the page with decoration.',
95
+ page_bottom_empty:
96
+ 'Let a long table break across the page, move a shorter block up, or tighten the blocks above; never pad the foot with decoration.',
95
97
  broken_chart: 'Rebuild the native chart from source-bound data, save, close, and verify the series after reopen.',
96
98
  empty_chart:
97
99
  'Populate the chart embedded workbook from the cited source range, then verify seriesCount after reopen.',
@@ -147,6 +149,12 @@ const POLISH_GUIDANCE = Object.freeze({
147
149
  heading_style_inconsistent:
148
150
  'Set every heading of one level in the same type; a level that changes size mid-document reads as two levels.',
149
151
  short_table_split: 'Keep the short table together or move it intact to the next page.',
152
+ table_label_oversized:
153
+ "Set the table's labels at caption size and keep display size for its figures: add_table properties.rowStyles, or set_table_cell_style on the label row.",
154
+ total_row_unmarked:
155
+ 'Set the total apart from the rows it sums: add_table properties.totalRow:true in Word, set_style bold with borders.top on the row in Excel.',
156
+ header_alignment_mismatch:
157
+ "Align the header with its column: a column of figures stands on its right edge, so its header takes horizontalAlignment:'right'.",
150
158
  shape_overlap: 'Move or resize the reported shapes while preserving a consistent alignment grid.',
151
159
  text_overflow: 'Shorten the copy or enlarge the text area before reducing type size.',
152
160
  small_font: 'Reduce content density or split the slide; keep presentation body text at least 12 pt.',
@@ -244,7 +252,7 @@ const POLISH_GUIDANCE = Object.freeze({
244
252
  // Text fit and placement (portable review).
245
253
  text_clipped: 'Enlarge the box or shorten the copy; text cut at a box edge is always visible to the reader.',
246
254
  text_box_too_narrow: 'Widen the text box so lines wrap at a readable measure instead of one or two words per line.',
247
- shape_out_of_bounds: 'Move or resize the shape inside the 13.33 × 7.5 canvas; nothing past the edge is shown.',
255
+ shape_out_of_bounds: 'Move or resize the shape inside the slide canvas; nothing past the edge is shown.',
248
256
  oversized_heading_text:
249
257
  'Set the paragraph in the body style, or lift its first line out as a heading over body text: prose at heading size reads as one shouted block.',
250
258
  shapes_too_close: 'Open the gap between the shapes to at least 0.3 in, or merge them into one block.',
@@ -26,10 +26,10 @@ export const TOOL_DEFS = [
26
26
  type: 'string',
27
27
  enum: OFFICE_ACTIONS,
28
28
  description:
29
- 'detect/describe discover; author: HTML/pptxgenjs PPTX; transactions/recover/begin/diff/commit/rollback checkpoints; create/attach/open; snapshot/get/query; batch edits; issues/qa/validate review; render exports PDF and page images; save/finalize/close; secure: PDF passwords.',
29
+ 'detect/describe discover; author: PPTX/PDF/DOCX/XLSX; transactions/recover/begin/diff/commit/rollback checkpoints; create/attach/open; snapshot/get/query; batch edits; issues/qa/validate review; render exports PDF and page images; save/finalize/close; secure: PDF passwords.',
30
30
  },
31
31
  path: { type: 'string', description: 'Document path; relative paths resolve from the caller project.' },
32
- script: { type: 'string', description: 'author: HTML slides or a pptxgenjs script (pptx skill).' },
32
+ script: { type: 'string', description: 'author: HTML, or pptxgenjs for .pptx; see the format skill.' },
33
33
  render: {
34
34
  type: 'boolean',
35
35
  description:
@@ -0,0 +1,317 @@
1
+ /**
2
+ * A quota rise is the recorded requests plus nonnegative outside usage.
3
+ * Fit a heavy-tailed measurement component and an exponentially modified
4
+ * Gaussian outside component, rather than dividing cost by the whole rise.
5
+ * A sustained excess meter rise within the usual reach of outside use is NOT
6
+ * evidence of a smaller allowance: it is observationally identical to
7
+ * sustained web use. Historical cost/meter evidence constrains the own-use
8
+ * rate before outside usage is inferred; only a whole new window shifted
9
+ * beyond that reach is read as a new allowance.
10
+ *
11
+ * Recent observations carry more information than old ones. The historical
12
+ * ratio is available from the first priced observation; evidence for mixed
13
+ * use continuously increases the correction, without a sample-count gate.
14
+ * Earlier windows of the same limit continue into a new window, so a value
15
+ * exists from its first reading and settles on the new window's own evidence.
16
+ *
17
+ * The noise scale is an assumption, NOT a provider accuracy guarantee.
18
+ * Continuous proportional outside use is unidentifiable from these inputs;
19
+ * callers must describe the result as a Mixdog-pattern estimate.
20
+ */
21
+ const NOISE = 0.06;
22
+ const DEFAULT_RESOLUTION = 1;
23
+ const MAX_SAMPLES = 180;
24
+ // Influence halves per this many newer quota points. Idle time adds no
25
+ // evidence, so it never ages an observation by itself.
26
+ const HALF_LIFE_POINTS = 32;
27
+ const ROOT_TWO_PI = Math.sqrt(2 * Math.PI);
28
+ const FLOOR = 1e-300;
29
+
30
+ /**
31
+ * One-sided support from chronological prefixes, before the recent-fit cap.
32
+ * Outside use can only enlarge the denominator, so even contaminated prefixes
33
+ * provide conservative lower support for own dollars per quota point.
34
+ * Endpoint rounding cancels within a contiguous block, not across missing
35
+ * observations. The union-bound margin accounts for inspecting all prefixes.
36
+ * This uses the assumed NOISE model; it is not a provider confidence guarantee.
37
+ */
38
+ function ownValueSupport(history) {
39
+ const z = Math.sqrt(2 * Math.log((2 * Math.max(1, history.length)) / 0.01));
40
+ let cost = 0;
41
+ let points = 0;
42
+ let squares = 0;
43
+ let rounding = 0;
44
+ let blockResolution = 0;
45
+ let previous = null;
46
+ let support = 0;
47
+ for (const row of history) {
48
+ if (
49
+ !row.priced ||
50
+ row.measured === false ||
51
+ !(row.costUsd > 0) ||
52
+ !Number.isFinite(row.costUsd) ||
53
+ !(row.points > 0) ||
54
+ !Number.isFinite(row.points)
55
+ ) {
56
+ previous = null;
57
+ continue;
58
+ }
59
+ const resolution = row.resolution ?? DEFAULT_RESOLUTION;
60
+ if (!previous || (row.fromMs != null && previous.toMs !== row.fromMs)) {
61
+ blockResolution = resolution;
62
+ rounding += resolution;
63
+ } else if (resolution > blockResolution) {
64
+ rounding += resolution - blockResolution;
65
+ blockResolution = resolution;
66
+ }
67
+ cost += row.costUsd;
68
+ points += row.points;
69
+ squares += row.costUsd ** 2;
70
+ support = Math.max(support, (cost - z * NOISE * Math.sqrt(squares)) / (points + rounding));
71
+ previous = row;
72
+ }
73
+ return support;
74
+ }
75
+
76
+ // Abramowitz–Stegun 7.1.26, evaluated as erfc to retain the normal tail.
77
+ function erfc(value) {
78
+ const x = Math.abs(value);
79
+ const t = 1 / (1 + 0.3275911 * x);
80
+ const tail =
81
+ ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
82
+ return value < 0 ? 2 - tail : tail;
83
+ }
84
+
85
+ function weightedQuantile(sorted, fraction, total) {
86
+ let weight = 0;
87
+ for (const sample of sorted) {
88
+ weight += sample.weight;
89
+ if (weight >= total * fraction) return sample.points / sample.costUsd;
90
+ }
91
+ return sorted.at(-1).points / sorted.at(-1).costUsd;
92
+ }
93
+
94
+ function densities(sample, rate, scale) {
95
+ const quality = sample.measured === false ? 0.25 : 1;
96
+ // A rise is the DIFFERENCE of two rounded meter readings. Their endpoint
97
+ // errors contribute q²/12 each; internal rounding errors cancel when
98
+ // adjacent intervals are combined. Whole-percent meters are not 0.1% data.
99
+ const roundingNoise = (sample.resolution ?? DEFAULT_RESOLUTION) / Math.sqrt(6);
100
+ const sigma =
101
+ Math.hypot(NOISE * rate, roundingNoise / sample.costUsd, sample.measured === false ? rate : 0) / Math.sqrt(quality);
102
+ const delta = sample.points / sample.costUsd - rate;
103
+ const tau = scale * rate;
104
+ // A small Student-t tail tolerates obsolete or irregular measurements
105
+ // without giving up the Gaussian core's sensitivity to small mixed use.
106
+ const normal = Math.exp(-0.5 * (delta / sigma) ** 2) / (ROOT_TWO_PI * sigma);
107
+ const tail = (0.375 / sigma) * (1 + (delta / sigma) ** 2 / 4) ** -2.5;
108
+ const clean = Math.max(FLOOR, 0.95 * normal + 0.05 * tail);
109
+ const outside = Math.max(
110
+ FLOOR,
111
+ (Math.exp(Math.min(700, sigma ** 2 / (2 * tau ** 2) - delta / tau)) *
112
+ erfc((sigma ** 2 / tau - delta) / (Math.SQRT2 * sigma))) /
113
+ (2 * tau)
114
+ );
115
+ return { clean, outside, sigma, delta, tau };
116
+ }
117
+
118
+ const byEnd = (a, b) => (a.toMs ?? 0) - (b.toMs ?? 0);
119
+ const usable = (row) =>
120
+ row.priced && Number.isFinite(row.costUsd) && row.costUsd > 0 && Number.isFinite(row.points) && row.points > 0;
121
+
122
+ /** One priced observation with its measurement weight. An inferred opening or
123
+ * saturated meter is weaker evidence, not a reason to discard its dollars. */
124
+ function observation(row) {
125
+ const quality = row.measured === false ? 0.25 : 1;
126
+ const resolution = row.resolution ?? DEFAULT_RESOLUTION;
127
+ const precision = row.points ** 2 / (row.points ** 2 + resolution ** 2 / 6);
128
+ return { ...row, resolution, weight: quality * precision };
129
+ }
130
+
131
+ // Week-to-week drift of one allowance's value (model mix, list prices), and
132
+ // how far outside use routinely pulls a window's own estimate down.
133
+ const WINDOW_DRIFT = 0.08;
134
+ const OUTSIDE_PULL = 0.25;
135
+
136
+ /**
137
+ * How strongly the current window's own value contradicts the earlier
138
+ * windows', as a weight for a new allowance. Its spread is the endpoint
139
+ * rounding of `points` plus per-reading noise and drift. Outside use can only
140
+ * LOWER a window's own value, so a lower value must also exceed that pull; a
141
+ * higher one cannot come from outside use at all.
142
+ */
143
+ function allowanceChange(own, earlier, points) {
144
+ const spread = Math.hypot(0.5 / points, NOISE / Math.sqrt(points), WINDOW_DRIFT);
145
+ const shift = Math.log(own / earlier);
146
+ const z = (shift > 0 ? shift : Math.max(0, -shift - OUTSIDE_PULL)) / spread;
147
+ return 1 / (1 + Math.exp(-2 * (z - 3)));
148
+ }
149
+
150
+ /**
151
+ * Observations of ONE account's limit: `intervals` from the current window,
152
+ * `prior` from its earlier windows. No sample-count gate is required to
153
+ * display an estimate: the earlier windows supply one from the first reading.
154
+ * The pooled fit continues the earlier windows until the current window's
155
+ * own value clearly contradicts them. A new allowance that raises the value,
156
+ * or lowers it further than outside use routinely does, takes over within a
157
+ * few points; a smaller downward shift is followed as the earlier evidence
158
+ * decays instead.
159
+ */
160
+ export function estimateQuotaValue(intervals, prior = []) {
161
+ const ownValueLowerBound = ownValueSupport(intervals.toSorted(byEnd));
162
+ const own = fitQuotaValue(intervals, [], ownValueLowerBound);
163
+ const earlier = prior.filter(usable);
164
+ if (!earlier.length) return own;
165
+ const pooled = fitQuotaValue(intervals, earlier, ownValueLowerBound);
166
+ if (!own) return pooled;
167
+ const points = intervals
168
+ .filter((row) => usable(row) && row.measured !== false)
169
+ .reduce((sum, row) => sum + row.points, 0);
170
+ const changeWeight =
171
+ points > 0 ? allowanceChange(own.costPerPercent, fitQuotaValue([], earlier, 0).costPerPercent, points) : 0;
172
+ const costPerPercent = (1 - changeWeight) * pooled.costPerPercent + changeWeight * own.costPerPercent;
173
+ return { ...(changeWeight > 0.5 ? own : pooled), rate: 1 / costPerPercent, costPerPercent, changeWeight };
174
+ }
175
+
176
+ /** The mixture fit over the current window and any earlier windows. Recent
177
+ * fitting is bounded, but older own-use evidence must not expire merely
178
+ * because web use persists. The allowance may change between windows, so
179
+ * only the current window's own evidence bounds the rate: a provably higher
180
+ * value takes effect at once. */
181
+ function fitQuotaValue(intervals, prior, ownValueLowerBound) {
182
+ const rateCeiling = ownValueLowerBound > 0 ? 1 / ownValueLowerBound : Infinity;
183
+ const history = [...prior, ...intervals].filter(usable).toSorted(byEnd).slice(-MAX_SAMPLES);
184
+ if (!history.length) return null;
185
+ let newerPoints = 0;
186
+ const samples = history
187
+ .toReversed()
188
+ .map((row) => {
189
+ const recency = 2 ** (-newerPoints / HALF_LIFE_POINTS);
190
+ newerPoints += row.points;
191
+ const sample = observation(row);
192
+ return { ...sample, weight: recency * sample.weight };
193
+ })
194
+ .reverse();
195
+ const information = samples.reduce((sum, row) => sum + row.weight, 0);
196
+ const cost = samples.reduce((sum, row) => sum + row.weight * row.costUsd, 0);
197
+ const points = samples.reduce((sum, row) => sum + row.weight * row.points, 0);
198
+ // Never train a faster own-use rate from an excess total-meter rise that
199
+ // contradicts existing own-value support. Fit its outside component instead.
200
+ const historicalRate = Math.min(points / cost, rateCeiling);
201
+ const baselineScore = samples.reduce(
202
+ (sum, row) => sum + row.weight * Math.log(densities(row, historicalRate, 0.7).clean),
203
+ 0
204
+ );
205
+ const sorted = samples.toSorted((a, b) => a.points / a.costUsd - b.points / b.costUsd);
206
+ const lower = weightedQuantile(sorted, 0.1, information) * 0.65;
207
+ const upper = weightedQuantile(sorted, 0.6, information) * 1.15;
208
+ let step = (upper - lower) / 40;
209
+ let candidates = [historicalRate, ...Array.from({ length: 41 }, (_, index) => lower + index * step)];
210
+ let best = { rate: historicalRate, scale: 0.7, mixed: 0 };
211
+ let bestScore = baselineScore;
212
+ for (let refinement = 0; refinement < 3; refinement += 1) {
213
+ for (const rate of candidates) {
214
+ if (!(rate > 0) || rate > rateCeiling) continue;
215
+ for (const scale of [0.15, 0.7, 3]) {
216
+ const pairs = samples.map((sample) => densities(sample, rate, scale));
217
+ for (const mixed of [0, 0.25, 0.5, 0.75, 0.9]) {
218
+ let score = 0;
219
+ for (let index = 0; index < pairs.length; index += 1) {
220
+ const pair = pairs[index];
221
+ score += samples[index].weight * Math.log((1 - mixed) * pair.clean + mixed * pair.outside);
222
+ }
223
+ if (score > bestScore) {
224
+ bestScore = score;
225
+ best = { rate, scale, mixed };
226
+ }
227
+ }
228
+ }
229
+ }
230
+ const radius = step;
231
+ step /= 5;
232
+ candidates = Array.from({ length: 11 }, (_, index) => best.rate - radius + index * step);
233
+ }
234
+ // The two additional mixture parameters pay a log-information penalty.
235
+ // The remaining likelihood evidence smoothly controls the correction:
236
+ // no arbitrary number of records changes the estimator's mode.
237
+ const evidence = Math.max(0, bestScore - baselineScore - Math.log1p(information));
238
+ const correctionWeight = -Math.expm1(-evidence);
239
+ const costPerPercent = (1 - correctionWeight) / historicalRate + correctionWeight / best.rate;
240
+ const mixed = correctionWeight * best.mixed;
241
+ return {
242
+ rate: 1 / costPerPercent,
243
+ costPerPercent,
244
+ mixed,
245
+ scale: best.scale,
246
+ correctionWeight,
247
+ ownValueLowerBound,
248
+ information,
249
+ resolution: samples.at(-1).resolution,
250
+ samples: samples.length,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Spans where a finer meter rose beyond what Mixdog's own spending explains.
256
+ * Each finer window restarts from an exact zero, so a whole-percent reading v
257
+ * proves more than v - 1 points of use since its start, whatever the rounding.
258
+ * Its dollars per point come from the median window (true use ≈ v - 0.5): the
259
+ * median ignores the few windows that outside use made cheap. Spending up to
260
+ * `lagMs` after a reading counts, since requests are recorded on completion.
261
+ * Thirty percent of the expected use plus a quarter point is ordinary weight
262
+ * noise; a rise with no spending at all is outside use outright. After outside
263
+ * use, only further growth of the unexplained excess marks another span.
264
+ */
265
+ export function outsideSpans(windows, costBetween, { lagMs = 5 * 60_000 } = {}) {
266
+ const rates = windows
267
+ .filter((window) => window.readings.at(-1).usedPct >= 4)
268
+ .map((window) => {
269
+ const last = window.readings.at(-1);
270
+ return costBetween(window.startMs, last.ts + lagMs) / (last.usedPct - 0.5);
271
+ })
272
+ .sort((a, b) => a - b);
273
+ const rate = rates[Math.floor(rates.length / 2)];
274
+ if (!(rate > 0)) return [];
275
+ const spans = [];
276
+ for (const window of windows) {
277
+ let fromMs = window.startMs;
278
+ let previous = 0;
279
+ let excess = 0;
280
+ for (const reading of window.readings) {
281
+ const expected = costBetween(window.startMs, reading.ts + lagMs) / rate;
282
+ const unexplained = reading.usedPct - 1 - expected;
283
+ if (
284
+ reading.usedPct > previous &&
285
+ (expected === 0 || (unexplained > 0.3 * expected + 0.25 && unexplained > excess))
286
+ ) {
287
+ spans.push({ fromMs, toMs: reading.ts });
288
+ }
289
+ excess = Math.max(excess, unexplained);
290
+ fromMs = reading.ts;
291
+ previous = reading.usedPct;
292
+ }
293
+ }
294
+ return spans;
295
+ }
296
+
297
+ /** Expected outside percentage points, bounded by the actual meter rise. */
298
+ export function estimateOutsideQuota(sample, model) {
299
+ if (!model || !(sample.points > 0) || !(sample.costUsd > 0) || model.mixed === 0) return 0;
300
+ const { clean, outside, sigma, delta, tau } = densities(
301
+ { ...sample, resolution: sample.resolution ?? model.resolution },
302
+ model.rate,
303
+ model.scale
304
+ );
305
+ const probability = (model.mixed * outside) / ((1 - model.mixed) * clean + model.mixed * outside);
306
+ const a = (delta - sigma ** 2 / tau) / sigma;
307
+ let extra;
308
+ if (a < -10) {
309
+ // The inverse Mills ratio's tail avoids subtracting two large numbers.
310
+ const z = -a;
311
+ extra = sigma * (1 / z - 2 / z ** 3 + 10 / z ** 5);
312
+ } else {
313
+ const cdf = 0.5 * erfc(-a / Math.SQRT2);
314
+ extra = sigma * (a + Math.exp(-0.5 * a * a) / (ROOT_TWO_PI * Math.max(FLOOR, cdf)));
315
+ }
316
+ return Math.min(sample.points, Math.max(0, sample.costUsd * probability * extra));
317
+ }