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.
- package/README.md +117 -434
- package/package.json +18 -17
- package/scripts/gate-local.mjs +93 -0
- package/scripts/lib/gate-scheduler.mjs +24 -0
- package/scripts/lib/run-node-tests.mjs +5 -1
- package/scripts/release-paths.mjs +22 -1
- package/scripts/test-direct.mjs +2 -1
- package/scripts/test.mjs +3 -1
- package/src/defaults/agents.json +0 -12
- package/src/defaults/mixdog-config.template.json +1 -2
- package/src/defaults/skills/docx/SKILL.md +7 -4
- package/src/defaults/skills/docx/references/html.md +46 -0
- package/src/defaults/skills/docx/references/native-authoring.md +18 -4
- package/src/defaults/skills/pdf/SKILL.md +10 -2
- package/src/defaults/skills/pdf/references/html.md +64 -0
- package/src/defaults/skills/pptx/references/html.md +1 -1
- package/src/defaults/skills/xlsx/SKILL.md +7 -3
- package/src/defaults/skills/xlsx/references/html.md +40 -0
- package/src/defaults/skills/xlsx/references/report-design.md +21 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -1
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -3
- package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/account-pool.mjs +6 -5
- package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +50 -9
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +28 -1
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +34 -8
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +10 -2
- package/src/runtime/agent/orchestrator/session/transcript-restore/agent-envelope.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +3 -1
- package/src/runtime/attachments/pdf-extract.mjs +7 -3
- package/src/runtime/computer-bridge/client.mjs +1 -1
- package/src/runtime/memory/index.mjs +4 -15
- package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
- package/src/runtime/memory/lib/cycle-llm-adapters.mjs +0 -2
- package/src/runtime/memory/lib/cycle-scheduler/backlog-probe.mjs +3 -11
- package/src/runtime/memory/lib/cycle-scheduler/health-ledger.mjs +1 -3
- package/src/runtime/memory/lib/cycle-scheduler/scheduled-enqueue.mjs +1 -11
- package/src/runtime/memory/lib/cycle-scheduler/tick-loop.mjs +0 -2
- package/src/runtime/memory/lib/cycle-scheduler.mjs +4 -13
- package/src/runtime/memory/lib/cycle-signatures.mjs +0 -6
- package/src/runtime/memory/lib/cycle1/cycle1-rows.mjs +1 -1
- package/src/runtime/memory/lib/cycle1/cycle1-window.mjs +2 -2
- package/src/runtime/memory/lib/embedding-reindex.mjs +1 -1
- package/src/runtime/memory/lib/http-router/lifecycle-routes.mjs +0 -1
- package/src/runtime/memory/lib/memory-action-handlers/cycle-actions.mjs +7 -52
- package/src/runtime/memory/lib/memory-action-handlers/cycle-backfill-action.mjs +6 -22
- package/src/runtime/memory/lib/memory-action-handlers/cycle-rebuild-action.mjs +10 -17
- package/src/runtime/memory/lib/memory-action-handlers/maintenance-actions.mjs +1 -3
- package/src/runtime/memory/lib/memory-action-handlers/manage-actions.mjs +2 -4
- package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -4
- package/src/runtime/memory/lib/memory-cycle-requests.mjs +1 -1
- package/src/runtime/memory/lib/{memory-cycle2-shared.mjs → memory-cycle-shared.mjs} +14 -16
- package/src/runtime/memory/lib/memory-cycle.mjs +3 -2
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
- package/src/runtime/memory/lib/memory-embed.mjs +14 -1
- package/src/runtime/memory/lib/memory-ops-policy.mjs +8 -11
- package/src/runtime/memory/lib/memory-schema/entries.mjs +0 -1
- package/src/runtime/memory/lib/memory.mjs +2 -6
- package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
- package/src/runtime/memory/lib/query-handlers.mjs +1 -1
- package/src/runtime/memory/lib/query-hybrid-search.mjs +1 -1
- package/src/runtime/memory/lib/query-maintenance-handlers.mjs +0 -6
- package/src/runtime/memory/lib/recall-format.mjs +1 -1
- package/src/runtime/office/authoring/docx-html-build.mjs +375 -0
- package/src/runtime/office/authoring/docx-html-measure.mjs +561 -0
- package/src/runtime/office/authoring/docx-html-runner.mjs +102 -0
- package/src/runtime/office/authoring/html-browser.mjs +61 -0
- package/src/runtime/office/authoring/html-document-author.mjs +181 -0
- package/src/runtime/office/authoring/html-source-drift.mjs +36 -0
- package/src/runtime/office/authoring/pdf-author-action.mjs +266 -0
- package/src/runtime/office/authoring/pdf-html-charts.mjs +379 -0
- package/src/runtime/office/authoring/pdf-html-runner.mjs +317 -0
- package/src/runtime/office/authoring/pptx-author-action.mjs +44 -22
- package/src/runtime/office/authoring/pptx-author-session.mjs +12 -6
- package/src/runtime/office/authoring/pptx-html-build.mjs +23 -4
- package/src/runtime/office/authoring/pptx-html-measure.mjs +38 -44
- package/src/runtime/office/authoring/pptx-html-runner.mjs +18 -3
- package/src/runtime/office/authoring/pptx-receipt.mjs +39 -31
- package/src/runtime/office/authoring/pptx-review-artifacts.mjs +9 -1
- package/src/runtime/office/authoring/pptx-script-runner.mjs +6 -2
- package/src/runtime/office/authoring/xlsx-html-build.mjs +365 -0
- package/src/runtime/office/authoring/xlsx-html-measure.mjs +257 -0
- package/src/runtime/office/authoring/xlsx-html-runner.mjs +139 -0
- package/src/runtime/office/capabilities-catalog.mjs +8 -0
- package/src/runtime/office/capabilities-signatures.mjs +18 -6
- package/src/runtime/office/capabilities.mjs +26 -1
- package/src/runtime/office/com/office-com-host.ps1 +133 -17
- package/src/runtime/office/core/office-actions-batch.mjs +16 -1
- package/src/runtime/office/core/office-actions-inspect.mjs +3 -2
- package/src/runtime/office/core/office-actions-lifecycle.mjs +6 -2
- package/src/runtime/office/core/office-actions-render.mjs +4 -1
- package/src/runtime/office/core/office-finalize/review-stage.mjs +15 -2
- package/src/runtime/office/core/office-qa/design-review-stage.mjs +9 -0
- package/src/runtime/office/core/office-session-dispatch.mjs +3 -0
- package/src/runtime/office/core/office-sessionless-actions.mjs +7 -2
- package/src/runtime/office/core/office-sessions.mjs +19 -2
- package/src/runtime/office/pdf/pdf-writer.mjs +1 -1
- package/src/runtime/office/portable/docx-formatting.mjs +21 -0
- package/src/runtime/office/portable/ooxml-validator.mjs +22 -1
- package/src/runtime/office/portable/portable-chart.mjs +10 -2
- package/src/runtime/office/portable/portable-docx-edits.mjs +12 -6
- package/src/runtime/office/portable/portable-docx-operations.mjs +75 -1
- package/src/runtime/office/portable/portable-docx-xml.mjs +49 -6
- package/src/runtime/office/portable/portable-docx.mjs +2 -0
- package/src/runtime/office/portable/portable-snapshot-docx.mjs +2 -0
- package/src/runtime/office/portable/portable-validation.mjs +6 -3
- package/src/runtime/office/portable/portable-xlsx-cell-values.mjs +87 -0
- package/src/runtime/office/portable/portable-xlsx-charts.mjs +55 -13
- package/src/runtime/office/portable/portable-xlsx-sheet-edits.mjs +1 -1
- package/src/runtime/office/portable/xlsx-contract.mjs +82 -3
- package/src/runtime/office/quality/assurance-checklist.mjs +2 -2
- package/src/runtime/office/quality/assurance-rendered.mjs +27 -1
- package/src/runtime/office/quality/assurance-structure-docx.mjs +44 -0
- package/src/runtime/office/quality/assurance-structure-pptx.mjs +24 -6
- package/src/runtime/office/quality/assurance-structure-xlsx.mjs +50 -1
- package/src/runtime/office/quality/design-review-critique.mjs +3 -2
- package/src/runtime/office/quality/document-brief.mjs +44 -0
- package/src/runtime/office/quality/inline-audit.mjs +7 -2
- package/src/runtime/office/quality/quality-pipeline.mjs +9 -1
- package/src/runtime/office/tool-defs.mjs +2 -2
- package/src/runtime/shared/llm/quota-value-estimate.mjs +317 -0
- package/src/runtime/shared/llm/usage-ledger-quota.mjs +266 -63
- package/src/runtime/shared/llm/usage-ledger-rollup.mjs +35 -0
- package/src/runtime/shared/llm/usage-ledger-worker.mjs +2 -1
- package/src/runtime/shared/llm/usage-ledger.mjs +10 -1
- package/src/runtime/shared/provider-accounts.mjs +11 -1
- package/src/runtime/shared/statusline/statusline-segments.mjs +4 -4
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/wait-until.test-support.mjs +33 -0
- package/src/session-runtime/boot/apis.mjs +1 -1
- package/src/session-runtime/services/channel-admin.mjs +4 -3
- package/src/session-runtime/services/usage-stats-model.mjs +22 -0
- package/src/session-runtime/setup-tool/settings-contract.mjs +6 -1
- package/src/session-runtime/turn/session-ops.mjs +15 -9
- package/src/session-runtime/usage-stats-api.mjs +34 -2
- package/src/standalone/agent-dispatch-broker.mjs +1 -1
- package/src/standalone/local-session-runtime.mjs +488 -0
- package/src/standalone/session-protocol.mjs +1 -0
- package/src/standalone/session-runtime-inline-host.mjs +1 -1
- package/src/tui/hooks/useSession.mjs +1 -1
- package/src/tui/session/session-api/commands.mjs +1 -1
- package/src/tui/session/session-api/integrations.mjs +3 -0
- package/src/tui/session/session-flow/auto-clear.mjs +6 -7
- package/src/tui/session/tool-card-results.mjs +1 -1
- package/src/tui/session/turn.mjs +5 -0
- package/src/tui/session-local.mjs +3 -488
- package/src/tui/session.mjs +1 -1
- package/src/ui/statusline-agents.mjs +0 -1
- package/src/rules/agent/41-cycle2-agent.md +0 -24
- package/src/runtime/memory/lib/cycle-scheduler/cycle2-runs.mjs +0 -120
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +0 -110
- package/src/runtime/memory/lib/memory-cycle2-quarantine.mjs +0 -99
- package/src/runtime/memory/lib/memory-cycle2-review.mjs +0 -240
- 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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
+
}
|