proactive-gate 0.4.1 → 0.6.0
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 +83 -14
- package/README.tr.md +55 -3
- package/dist/src/checks.js +3 -1
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +92 -1
- package/dist/src/demo-week.d.ts +38 -0
- package/dist/src/demo-week.js +115 -0
- package/dist/src/explain.js +8 -2
- package/dist/src/presets.js +16 -2
- package/dist/src/simulate-report.d.ts +27 -0
- package/dist/src/simulate-report.js +128 -0
- package/dist/src/simulate.d.ts +118 -0
- package/dist/src/simulate.js +269 -0
- package/package.json +4 -3
- package/spec/CONFORMANCE.md +49 -3
- package/spec/SPEC.md +3 -0
- package/spec/SPEC_VERSION +1 -1
- package/spec/fixtures/adaptive-timing/placeholder.json +1 -1
- package/spec/fixtures/budget/bypass-priority.json +1 -1
- package/spec/fixtures/budget/daily-atomic-commit.json +1 -1
- package/spec/fixtures/budget/near-limit.json +1 -1
- package/spec/fixtures/budget/race-second-commit-loses.json +1 -1
- package/spec/fixtures/budget/weekly-iso-week.json +1 -1
- package/spec/fixtures/consent/required.json +1 -1
- package/spec/fixtures/cooldown/three-dismissals.json +1 -1
- package/spec/fixtures/dedupe/already-delivered.json +1 -1
- package/spec/fixtures/dedupe/no-key-skips.json +1 -1
- package/spec/fixtures/defer/snooze-as-defer.json +1 -1
- package/spec/fixtures/mode/allow-list.json +1 -1
- package/spec/fixtures/ordering/kill-switch.json +1 -1
- package/spec/fixtures/ordering/short-circuit.json +1 -1
- package/spec/fixtures/policy/unknown-check-is-an-error.json +1 -1
- package/spec/fixtures/presets/cn-minor-mode.json +1 -1
- package/spec/fixtures/presets/in-tcccp.json +258 -0
- package/spec/fixtures/presets/kakao-brand-message.json +1 -1
- package/spec/fixtures/presets/kr-network-act-50.json +1 -1
- package/spec/fixtures/presets/telegram-bot.json +1 -1
- package/spec/fixtures/presets/us-tcpa.json +1 -1
- package/spec/fixtures/quiet-hours/apia.json +1 -1
- package/spec/fixtures/quiet-hours/caller-supplied-dates.json +1 -1
- package/spec/fixtures/quiet-hours/crosses-midnight-by-day.json +1 -1
- package/spec/fixtures/quiet-hours/dst-new-york.json +1 -1
- package/spec/fixtures/quiet-hours/istanbul.json +1 -1
- package/spec/fixtures/quiet-hours/wall-clock.json +1 -1
- package/spec/fixtures/quiet-hours/weekday-schedule.json +1 -1
- package/spec/fixtures/shadow/reject-continues.json +1 -1
- package/spec/fixtures/trust-ramp/first-week.json +1 -1
- package/spec/fixtures/utility/bounded-deferral-cap.json +1 -1
- package/spec/fixtures/utility/bounded-deferral.json +1 -1
- package/spec/fixtures/utility/floor.json +1 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The simulation as a table. Rendering only: every number here was computed in simulate.ts,
|
|
3
|
+
* and nothing in this file recounts anything, so the terminal output and `--json` cannot
|
|
4
|
+
* disagree with each other.
|
|
5
|
+
*/
|
|
6
|
+
import { DEMO_WEEK_NOTE } from "./demo-week.js";
|
|
7
|
+
import type { SimResult } from "./simulate.js";
|
|
8
|
+
export interface ReportOptions {
|
|
9
|
+
/** How many timeline rows to print. 0 prints all of them. */
|
|
10
|
+
limit?: number;
|
|
11
|
+
/** Print only the candidates the policies disagreed about. */
|
|
12
|
+
disagreementsOnly?: boolean;
|
|
13
|
+
/** Print each held candidate's sentence under its row. */
|
|
14
|
+
why?: boolean;
|
|
15
|
+
/** Which run the sentences and the stopped-by table describe; the last policy by default. */
|
|
16
|
+
reasonsFor?: number;
|
|
17
|
+
night?: {
|
|
18
|
+
start: number;
|
|
19
|
+
end: number;
|
|
20
|
+
};
|
|
21
|
+
/** Appended under the tables, for a generated stream that has to say so. */
|
|
22
|
+
note?: string;
|
|
23
|
+
/** One line per user, for a generated stream whose people were chosen for a reason. */
|
|
24
|
+
people?: string[];
|
|
25
|
+
}
|
|
26
|
+
export declare function formatSimulation(result: SimResult, options?: ReportOptions): string;
|
|
27
|
+
export { DEMO_WEEK_NOTE };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The simulation as a table. Rendering only: every number here was computed in simulate.ts,
|
|
3
|
+
* and nothing in this file recounts anything, so the terminal output and `--json` cannot
|
|
4
|
+
* disagree with each other.
|
|
5
|
+
*/
|
|
6
|
+
import { DEMO_WEEK_NOTE } from "./demo-week.js";
|
|
7
|
+
const VERDICT = {
|
|
8
|
+
sent: "sent",
|
|
9
|
+
held: "held",
|
|
10
|
+
deferred: "deferred",
|
|
11
|
+
expired: "expired",
|
|
12
|
+
lostAtCommit: "lost at commit",
|
|
13
|
+
spentNotDelivered: "spent, not delivered",
|
|
14
|
+
};
|
|
15
|
+
/** Outcome plus the check that produced it, which is the whole verdict in one cell. */
|
|
16
|
+
const verdictOf = (cell) => {
|
|
17
|
+
if (!cell)
|
|
18
|
+
return "";
|
|
19
|
+
const word = VERDICT[cell.outcome] ?? cell.outcome;
|
|
20
|
+
return cell.check && cell.outcome !== "sent" ? `${word} ${cell.check}` : word;
|
|
21
|
+
};
|
|
22
|
+
const pad = (s, w) => (s.length > w ? `${s.slice(0, Math.max(0, w - 1))}…` : s.padEnd(w));
|
|
23
|
+
/** The widest cell in a column, so nothing is truncated that would have fitted. */
|
|
24
|
+
const widthOf = (values, min) => Math.max(min, ...values.map((v) => v.length));
|
|
25
|
+
function timelineTable(result, rows, why) {
|
|
26
|
+
const labels = result.runs.map((r) => r.label);
|
|
27
|
+
const cols = labels.map((label, i) => ({
|
|
28
|
+
label,
|
|
29
|
+
width: widthOf([label, ...rows.map((r) => verdictOf(r.cells[i]))], 8),
|
|
30
|
+
}));
|
|
31
|
+
const userW = widthOf(["user", ...rows.map((r) => r.userId)], 4);
|
|
32
|
+
const typeW = widthOf(["type", ...rows.map((r) => r.type)], 4);
|
|
33
|
+
const priW = widthOf(["pri", ...rows.map((r) => r.priority)], 3);
|
|
34
|
+
const head = `${pad("when (local)", 13)} ${pad("user", userW)} ${pad("type", typeW)} ${pad("pri", priW)} ${cols.map((c) => pad(c.label, c.width)).join(" ")}`;
|
|
35
|
+
const out = [head, "-".repeat(head.length)];
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
const cells = cols.map((c, i) => pad(verdictOf(row.cells[i]), c.width)).join(" ");
|
|
38
|
+
out.push(`${pad(row.localTime, 13)} ${pad(row.userId, userW)} ${pad(row.type, typeW)} ${pad(row.priority, priW)} ${cells}`.trimEnd());
|
|
39
|
+
// --why prints the sentence explain() already produces, under the row it belongs to.
|
|
40
|
+
const cell = why === null ? undefined : row.cells[why];
|
|
41
|
+
if (cell && cell.outcome !== "sent" && cell.sentence)
|
|
42
|
+
out.push(` ${cell.sentence}`);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
function summaryTable(runs, night) {
|
|
47
|
+
const labels = runs.map((r) => r.label);
|
|
48
|
+
const nightLabel = `delivered ${String(night.start).padStart(2, "0")}:00-${String(night.end).padStart(2, "0")}:00 local`;
|
|
49
|
+
const quietLabel = "delivered inside their own quiet hours";
|
|
50
|
+
const rows = [
|
|
51
|
+
["delivered", runs.map((r) => String(r.counts.sent))],
|
|
52
|
+
["held", runs.map((r) => String(r.counts.held))],
|
|
53
|
+
["deferred, then delivered", runs.map((r) => String(r.counts.sentAfterDeferral))],
|
|
54
|
+
["moved to a later moment", runs.map((r) => String(r.counts.sentAtALaterMoment))],
|
|
55
|
+
["deferred, then expired", runs.map((r) => String(r.counts.expired))],
|
|
56
|
+
["lost at commit", runs.map((r) => String(r.counts.lostAtCommit))],
|
|
57
|
+
["spent, not delivered", runs.map((r) => String(r.counts.spentNotDelivered))],
|
|
58
|
+
[quietLabel, runs.map((r) => String(r.counts.sentInQuietHours))],
|
|
59
|
+
[" of those, critical (what the floor lets through)", runs.map((r) => String(r.counts.sentInQuietHoursByFloor))],
|
|
60
|
+
[`${nightLabel} (a fixed window)`, runs.map((r) => String(r.counts.sentAtNight))],
|
|
61
|
+
["most one person got in a day", runs.map((r) => String(r.counts.busiestUserDay))],
|
|
62
|
+
];
|
|
63
|
+
const labelW = widthOf(["", ...rows.map((r) => r[0])], 12);
|
|
64
|
+
const colW = labels.map((l, i) => widthOf([l, ...rows.map((r) => r[1][i] ?? "")], 6));
|
|
65
|
+
const out = [
|
|
66
|
+
`${pad("", labelW)} ${labels.map((l, i) => pad(l, colW[i] ?? 6)).join(" ")}`,
|
|
67
|
+
"-".repeat(labelW + 2 + colW.reduce((n, w) => n + w + 2, 0)),
|
|
68
|
+
];
|
|
69
|
+
for (const [label, values] of rows) {
|
|
70
|
+
if (values.every((v) => v === "0"))
|
|
71
|
+
continue;
|
|
72
|
+
out.push(`${pad(label, labelW)} ${values.map((v, i) => pad(v, colW[i] ?? 6)).join(" ")}`.trimEnd());
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
function stoppedTable(run) {
|
|
77
|
+
if (!run.counts.stoppedBy.length)
|
|
78
|
+
return [];
|
|
79
|
+
const checkW = widthOf(["check", ...run.counts.stoppedBy.map((s) => s.check)], 5);
|
|
80
|
+
const out = [
|
|
81
|
+
`${pad("check", checkW)} ${pad("stopped", 7)} example`,
|
|
82
|
+
"-".repeat(checkW + 2 + 7 + 2 + 40),
|
|
83
|
+
];
|
|
84
|
+
for (const s of run.counts.stoppedBy)
|
|
85
|
+
out.push(`${pad(s.check, checkW)} ${String(s.count).padStart(7)} ${s.example}`);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
export function formatSimulation(result, options = {}) {
|
|
89
|
+
const limit = options.limit ?? 20;
|
|
90
|
+
const night = options.night ?? { start: 22, end: 8 };
|
|
91
|
+
const users = new Set(result.timeline.map((r) => r.userId)).size;
|
|
92
|
+
const source = options.disagreementsOnly ? result.disagreements : result.timeline;
|
|
93
|
+
const rows = limit > 0 ? source.slice(0, limit) : source;
|
|
94
|
+
const reasonsFor = options.reasonsFor ?? result.runs.length - 1;
|
|
95
|
+
const out = [
|
|
96
|
+
`proactive-gate simulate · seed ${result.seed} · ${result.events} candidates, ${users} users`,
|
|
97
|
+
`policies: ${result.runs.map((r) => r.label).join(" | ")}`,
|
|
98
|
+
"rows are in the order a server produced them; the time is the recipient's own clock",
|
|
99
|
+
"",
|
|
100
|
+
...timelineTable(result, rows, options.why ? reasonsFor : null),
|
|
101
|
+
];
|
|
102
|
+
if (rows.length < source.length) {
|
|
103
|
+
out.push(`${source.length - rows.length} more ${options.disagreementsOnly ? "disagreements" : "candidates"} not shown; --limit 0 prints every row, --json prints everything`);
|
|
104
|
+
}
|
|
105
|
+
out.push("", `the policies disagreed about ${result.disagreements.length} of ${result.events} candidates`, "");
|
|
106
|
+
out.push(...summaryTable(result.runs, night));
|
|
107
|
+
const subject = result.runs[reasonsFor];
|
|
108
|
+
if (subject && subject.counts.stoppedBy.length) {
|
|
109
|
+
out.push("", `why ${subject.label} did not send`, "");
|
|
110
|
+
out.push(...stoppedTable(subject));
|
|
111
|
+
}
|
|
112
|
+
const budget = subject?.budget ?? [];
|
|
113
|
+
if (budget.length) {
|
|
114
|
+
const busiest = [...budget].sort((a, b) => b.used - a.used || a.userId.localeCompare(b.userId)).slice(0, 5);
|
|
115
|
+
out.push("", `budget spent, read back out of the store (top ${busiest.length} of ${budget.length} user-days)`, "");
|
|
116
|
+
for (const row of busiest)
|
|
117
|
+
out.push(` ${pad(row.userId, 10)} ${row.localDay} ${row.used}`);
|
|
118
|
+
}
|
|
119
|
+
if (options.people?.length) {
|
|
120
|
+
out.push("", "who is in the week, and why each one is here", "");
|
|
121
|
+
for (const line of options.people)
|
|
122
|
+
out.push(` ${line}`);
|
|
123
|
+
}
|
|
124
|
+
if (options.note)
|
|
125
|
+
out.push("", options.note);
|
|
126
|
+
return out.join("\n");
|
|
127
|
+
}
|
|
128
|
+
export { DEMO_WEEK_NOTE };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Check, EvaluateInput, Policy } from "./types.js";
|
|
2
|
+
/** What became of one candidate under one policy. */
|
|
3
|
+
export type SimOutcome = "sent" | "held" | "deferred" | "expired" | "lostAtCommit" | "spentNotDelivered";
|
|
4
|
+
export interface SimRecord {
|
|
5
|
+
candidateId: string;
|
|
6
|
+
userId: string;
|
|
7
|
+
/** The instant this attempt was evaluated at, which for a retry is the deferral's own time. */
|
|
8
|
+
at: string;
|
|
9
|
+
/** 1 for the first evaluation, 2 and up for re-evaluations after a deferral. */
|
|
10
|
+
attempt: number;
|
|
11
|
+
outcome: SimOutcome;
|
|
12
|
+
rejectedBy?: string;
|
|
13
|
+
deferredBy?: string;
|
|
14
|
+
retryAt?: string;
|
|
15
|
+
/** Set when a non-rejecting check asked for the send to happen at a later moment. */
|
|
16
|
+
deliverAt?: string;
|
|
17
|
+
/** The check's own reason, as the library words it. */
|
|
18
|
+
reason?: string;
|
|
19
|
+
/** The whole decision as one sentence, from `explain()`. */
|
|
20
|
+
sentence?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface SimCounts {
|
|
23
|
+
candidates: number;
|
|
24
|
+
/**
|
|
25
|
+
* Every count below is over final outcomes, one per candidate: a candidate deferred and then
|
|
26
|
+
* sent is one send, not a deferral and a send. `deferredAtLeastOnce` and `sentAfterDeferral`
|
|
27
|
+
* are how the holding shows up, and they are the argument that a deferral is not a drop.
|
|
28
|
+
*/
|
|
29
|
+
sent: number;
|
|
30
|
+
held: number;
|
|
31
|
+
expired: number;
|
|
32
|
+
lostAtCommit: number;
|
|
33
|
+
spentNotDelivered: number;
|
|
34
|
+
deferredAtLeastOnce: number;
|
|
35
|
+
sentAfterDeferral: number;
|
|
36
|
+
/** Sends a non-rejecting check moved to a later moment, which is neither a hold nor a drop. */
|
|
37
|
+
sentAtALaterMoment: number;
|
|
38
|
+
/**
|
|
39
|
+
* Sends that landed inside the recipient's **own** quiet hours. This is the number that says
|
|
40
|
+
* whether a stated preference was honoured; a fixed curfew would punish `hana`, who asked for
|
|
41
|
+
* no quiet hours at all, and would miss a user whose window is 18:00 to 09:00.
|
|
42
|
+
*/
|
|
43
|
+
sentInQuietHours: number;
|
|
44
|
+
/** Of those, the ones the documented priority floor lets through on purpose. */
|
|
45
|
+
sentInQuietHoursByFloor: number;
|
|
46
|
+
/** Sends inside a fixed 22:00 to 08:00 local window, which is nobody's preference. */
|
|
47
|
+
sentAtNight: number;
|
|
48
|
+
/** The most sends one person received in one of their own local days. */
|
|
49
|
+
busiestUserDay: number;
|
|
50
|
+
/** How many candidates each check stopped, deferrals included, highest first. */
|
|
51
|
+
stoppedBy: Array<{
|
|
52
|
+
check: string;
|
|
53
|
+
count: number;
|
|
54
|
+
example: string;
|
|
55
|
+
}>;
|
|
56
|
+
}
|
|
57
|
+
export interface SimRun {
|
|
58
|
+
label: string;
|
|
59
|
+
records: SimRecord[];
|
|
60
|
+
counts: SimCounts;
|
|
61
|
+
/** Budget units spent, read back out of the store rather than counted in a local variable. */
|
|
62
|
+
budget: Array<{
|
|
63
|
+
userId: string;
|
|
64
|
+
localDay: string;
|
|
65
|
+
used: number;
|
|
66
|
+
}>;
|
|
67
|
+
}
|
|
68
|
+
export interface SimTimelineRow {
|
|
69
|
+
candidateId: string;
|
|
70
|
+
userId: string;
|
|
71
|
+
at: string;
|
|
72
|
+
/** Month-day and time in the recipient's own zone, which is the clock that judges a message. */
|
|
73
|
+
localTime: string;
|
|
74
|
+
type: string;
|
|
75
|
+
priority: string;
|
|
76
|
+
/** One cell per policy, in the order the policies were given. */
|
|
77
|
+
cells: Array<{
|
|
78
|
+
outcome: SimOutcome;
|
|
79
|
+
check?: string;
|
|
80
|
+
sentence?: string;
|
|
81
|
+
retryAt?: string;
|
|
82
|
+
}>;
|
|
83
|
+
}
|
|
84
|
+
export interface SimResult {
|
|
85
|
+
seed: number;
|
|
86
|
+
events: number;
|
|
87
|
+
runs: SimRun[];
|
|
88
|
+
timeline: SimTimelineRow[];
|
|
89
|
+
/** The candidates the policies disagreed about: the whole argument, in one list. */
|
|
90
|
+
disagreements: SimTimelineRow[];
|
|
91
|
+
}
|
|
92
|
+
/** A policy to run. Leave both `policy` and `checks` out for the no-gate baseline. */
|
|
93
|
+
export interface SimPolicy {
|
|
94
|
+
label: string;
|
|
95
|
+
policy?: Policy;
|
|
96
|
+
checks?: Check[];
|
|
97
|
+
}
|
|
98
|
+
export interface SimOptions {
|
|
99
|
+
events: EvaluateInput[];
|
|
100
|
+
policies: SimPolicy[];
|
|
101
|
+
/** Seeds the simulated transport, and nothing else. */
|
|
102
|
+
seed?: number;
|
|
103
|
+
/** Fraction of simulated sends the fake transport fails. 0 keeps the run about the policy. */
|
|
104
|
+
transportFailureRate?: number;
|
|
105
|
+
/** How long a deferred candidate stays worth sending before this scheduler drops it. */
|
|
106
|
+
expirySeconds?: number;
|
|
107
|
+
/** The local window counted as night in `sentAtNight`. */
|
|
108
|
+
night?: {
|
|
109
|
+
start: number;
|
|
110
|
+
end: number;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export declare const DEFAULT_NIGHT: {
|
|
114
|
+
start: number;
|
|
115
|
+
end: number;
|
|
116
|
+
};
|
|
117
|
+
export declare const DEFAULT_EXPIRY_SECONDS: number;
|
|
118
|
+
export declare function simulate(options: SimOptions): Promise<SimResult>;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One stream of candidates, two or more policies, every figure recomputed from the run.
|
|
3
|
+
*
|
|
4
|
+
* This adds no check and no store and does not touch the decision path: it runs the gate that
|
|
5
|
+
* already exists once per policy over the same events and records what happened to each
|
|
6
|
+
* candidate. The point is the difference between the columns, which is the only honest way this
|
|
7
|
+
* repository can answer "what would installing this change for me" without asking anyone to
|
|
8
|
+
* take a number on trust.
|
|
9
|
+
*
|
|
10
|
+
* Three distinctions the output keeps apart, because collapsing them is how a simulation lies:
|
|
11
|
+
*
|
|
12
|
+
* - **allowed is not sent.** `evaluate` says a message may go, `commit` takes the budget unit
|
|
13
|
+
* and can still refuse when a concurrent delivery took the last one, and only then does a
|
|
14
|
+
* transport run. `lostAtCommit` and `spentNotDelivered` are counted apart from `sent`.
|
|
15
|
+
* - **deferred is not rejected.** A deferral carries `retryAt`, and the candidate is
|
|
16
|
+
* re-evaluated from scratch at that instant against the same store, so consent, the clock and
|
|
17
|
+
* the budget are read again. An old allowed decision is never a standing permission to send.
|
|
18
|
+
* A deferral nobody could still act on is counted as `expired` rather than quietly dropped.
|
|
19
|
+
* - **no gate is not a policy.** The baseline bypasses the gate entirely: every candidate is
|
|
20
|
+
* sent. A policy with no checks is not expressible, and a rival built to lose would measure
|
|
21
|
+
* nothing. The baseline is what a product does before this library is installed.
|
|
22
|
+
*
|
|
23
|
+
* Nothing here reads a wall clock. Every event carries its own `now`, the deferral scheduler
|
|
24
|
+
* runs on those same instants, and the simulated transport draws from a seeded generator, so a
|
|
25
|
+
* replay gives the same result on any machine on any day.
|
|
26
|
+
*/
|
|
27
|
+
import { createGate } from "./gate.js";
|
|
28
|
+
import { budgetKey, localClock, quietAt } from "./checks.js";
|
|
29
|
+
import { explain } from "./explain.js";
|
|
30
|
+
import { MemoryStore } from "./stores.js";
|
|
31
|
+
export const DEFAULT_NIGHT = { start: 22, end: 8 };
|
|
32
|
+
export const DEFAULT_EXPIRY_SECONDS = 4 * 60 * 60;
|
|
33
|
+
/** Small deterministic PRNG, so a seed is the whole definition of a run. */
|
|
34
|
+
function mulberry32(seed) {
|
|
35
|
+
let a = seed >>> 0;
|
|
36
|
+
return () => {
|
|
37
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
38
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
39
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
40
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const iso = (d) => d.toISOString();
|
|
44
|
+
/** The recipient's own clock, which is the only one a notification is judged by. */
|
|
45
|
+
function localOf(timezone, at) {
|
|
46
|
+
const clock = localClock(at, timezone ?? "UTC");
|
|
47
|
+
const hour = Math.floor(clock.minutes / 60);
|
|
48
|
+
const minute = clock.minutes % 60;
|
|
49
|
+
return { hour, minute, day: clock.day, text: `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}` };
|
|
50
|
+
}
|
|
51
|
+
const inNight = (hour, night) => night.start <= night.end ? hour >= night.start && hour < night.end : hour >= night.start || hour < night.end;
|
|
52
|
+
function countsOf(records, events, night) {
|
|
53
|
+
const zones = new Map(events.map((e) => [e.user.id, e.user.timezone]));
|
|
54
|
+
const quiet = new Map(events.map((e) => [e.user.id, e.user.quietHours]));
|
|
55
|
+
const floor = new Map(events.map((e) => [e.candidate.id, e.candidate.priority ?? "normal"]));
|
|
56
|
+
const perUserDay = new Map();
|
|
57
|
+
const stopped = new Map();
|
|
58
|
+
// One row per candidate, the last attempt, because that is what became of it. Counting every
|
|
59
|
+
// attempt would report a candidate that was held until morning as both a deferral and a send.
|
|
60
|
+
const finals = new Map();
|
|
61
|
+
for (const r of records)
|
|
62
|
+
finals.set(r.candidateId, r);
|
|
63
|
+
const deferredIds = new Set(records.filter((r) => r.outcome === "deferred").map((r) => r.candidateId));
|
|
64
|
+
let sentAtNight = 0;
|
|
65
|
+
let sentInQuietHours = 0;
|
|
66
|
+
let sentInQuietHoursByFloor = 0;
|
|
67
|
+
for (const r of finals.values()) {
|
|
68
|
+
if (r.outcome === "sent") {
|
|
69
|
+
const at = new Date(r.at);
|
|
70
|
+
const local = localOf(zones.get(r.userId), at);
|
|
71
|
+
if (inNight(local.hour, night))
|
|
72
|
+
sentAtNight += 1;
|
|
73
|
+
const window = quiet.get(r.userId);
|
|
74
|
+
if (window && quietAt(window, local.day, local.hour * 60 + local.minute)) {
|
|
75
|
+
sentInQuietHours += 1;
|
|
76
|
+
if (floor.get(r.candidateId) === "critical")
|
|
77
|
+
sentInQuietHoursByFloor += 1;
|
|
78
|
+
}
|
|
79
|
+
const key = `${r.userId}:${local.day}`;
|
|
80
|
+
perUserDay.set(key, (perUserDay.get(key) ?? 0) + 1);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const by = r.rejectedBy ?? r.deferredBy;
|
|
84
|
+
if (!by)
|
|
85
|
+
continue;
|
|
86
|
+
const seen = stopped.get(by);
|
|
87
|
+
if (seen)
|
|
88
|
+
seen.count += 1;
|
|
89
|
+
else
|
|
90
|
+
stopped.set(by, { count: 1, example: r.reason ?? r.sentence ?? "" });
|
|
91
|
+
}
|
|
92
|
+
const finalRows = [...finals.values()];
|
|
93
|
+
const count = (outcome) => finalRows.filter((r) => r.outcome === outcome).length;
|
|
94
|
+
return {
|
|
95
|
+
candidates: finals.size,
|
|
96
|
+
sent: count("sent"),
|
|
97
|
+
held: count("held"),
|
|
98
|
+
expired: count("expired"),
|
|
99
|
+
lostAtCommit: count("lostAtCommit"),
|
|
100
|
+
spentNotDelivered: count("spentNotDelivered"),
|
|
101
|
+
deferredAtLeastOnce: deferredIds.size,
|
|
102
|
+
sentAfterDeferral: finalRows.filter((r) => r.outcome === "sent" && r.attempt > 1).length,
|
|
103
|
+
sentAtALaterMoment: finalRows.filter((r) => r.outcome === "sent" && r.deliverAt).length,
|
|
104
|
+
sentInQuietHours,
|
|
105
|
+
sentInQuietHoursByFloor,
|
|
106
|
+
sentAtNight,
|
|
107
|
+
busiestUserDay: perUserDay.size ? Math.max(...perUserDay.values()) : 0,
|
|
108
|
+
stoppedBy: [...stopped.entries()]
|
|
109
|
+
.map(([check, v]) => ({ check, count: v.count, example: v.example }))
|
|
110
|
+
.sort((a, b) => b.count - a.count || a.check.localeCompare(b.check)),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* One stream through one gate, with a scheduler for deferrals that belongs to this simulation
|
|
115
|
+
* rather than to the library: the library never runs a queue. The queue is drained per user just
|
|
116
|
+
* before that user's next event and again at the end, because budgets are keyed per user and
|
|
117
|
+
* local day, so each user's own order is what keeps their counter honest.
|
|
118
|
+
*/
|
|
119
|
+
async function runGate(label, gate, store, events, send, expirySeconds, night) {
|
|
120
|
+
const records = [];
|
|
121
|
+
const pending = [];
|
|
122
|
+
const evaluateOnce = async (user, candidate, now, attempt) => {
|
|
123
|
+
const input = { user, candidate, now };
|
|
124
|
+
const decision = await gate.evaluate(input);
|
|
125
|
+
const record = {
|
|
126
|
+
candidateId: candidate.id,
|
|
127
|
+
userId: user.id,
|
|
128
|
+
at: iso(now),
|
|
129
|
+
attempt,
|
|
130
|
+
outcome: "held",
|
|
131
|
+
...(decision.rejectedBy ? { rejectedBy: decision.rejectedBy } : {}),
|
|
132
|
+
...(decision.deferredBy ? { deferredBy: decision.deferredBy } : {}),
|
|
133
|
+
...(decision.retryAt ? { retryAt: iso(decision.retryAt) } : {}),
|
|
134
|
+
...(decision.deliverAt ? { deliverAt: iso(decision.deliverAt) } : {}),
|
|
135
|
+
...(decision.reason ? { reason: decision.reason } : {}),
|
|
136
|
+
sentence: explain(decision).summary,
|
|
137
|
+
};
|
|
138
|
+
if (decision.deferredBy) {
|
|
139
|
+
const due = decision.retryAt ?? now;
|
|
140
|
+
const stillWorthIt = due.getTime() <= now.getTime() + expirySeconds * 1000;
|
|
141
|
+
record.outcome = stillWorthIt ? "deferred" : "expired";
|
|
142
|
+
if (stillWorthIt)
|
|
143
|
+
pending.push({ user, candidate, due, attempt: attempt + 1 });
|
|
144
|
+
records.push(record);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!decision.allowed) {
|
|
148
|
+
records.push(record);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!(await gate.commit(decision, input))) {
|
|
152
|
+
record.outcome = "lostAtCommit";
|
|
153
|
+
record.rejectedBy = "commit";
|
|
154
|
+
record.reason = "a budget was exhausted at commit";
|
|
155
|
+
records.push(record);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
record.outcome = send() ? "sent" : "spentNotDelivered";
|
|
159
|
+
records.push(record);
|
|
160
|
+
};
|
|
161
|
+
/** Re-evaluate every deferral for this user due by `upTo`; both null drains what is left. */
|
|
162
|
+
const drain = async (userId, upTo) => {
|
|
163
|
+
for (;;) {
|
|
164
|
+
const index = pending.findIndex((p) => (userId === null || p.user.id === userId) && (upTo === null || p.due.getTime() <= upTo.getTime()));
|
|
165
|
+
if (index < 0)
|
|
166
|
+
return;
|
|
167
|
+
const [item] = pending.splice(index, 1);
|
|
168
|
+
if (!item)
|
|
169
|
+
return;
|
|
170
|
+
await evaluateOnce(item.user, item.candidate, item.due, item.attempt);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
for (const event of events) {
|
|
174
|
+
const now = event.now ?? new Date(0);
|
|
175
|
+
await drain(event.user.id, now);
|
|
176
|
+
await evaluateOnce(event.user, event.candidate, now, 1);
|
|
177
|
+
}
|
|
178
|
+
await drain(null, null);
|
|
179
|
+
// Budget rows come out of the store, one per user and per local day, because that is how the
|
|
180
|
+
// key is shaped (spec/SPEC.md 5.1). One number per user would report a counter that has just
|
|
181
|
+
// rolled over and hide what the day before spent.
|
|
182
|
+
const zones = new Map(events.map((e) => [e.user.id, e.user.timezone]));
|
|
183
|
+
const wanted = new Map();
|
|
184
|
+
for (const r of records) {
|
|
185
|
+
const key = budgetKey(r.userId, new Date(r.at), zones.get(r.userId));
|
|
186
|
+
if (!wanted.has(key))
|
|
187
|
+
wanted.set(key, { userId: r.userId, localDay: key.slice(key.lastIndexOf(":") + 1), key });
|
|
188
|
+
}
|
|
189
|
+
const budget = [];
|
|
190
|
+
for (const row of [...wanted.values()].sort((a, b) => a.userId.localeCompare(b.userId) || a.localDay.localeCompare(b.localDay))) {
|
|
191
|
+
const used = Number((await store.get(`pg:${row.key}`)) ?? 0);
|
|
192
|
+
if (used > 0)
|
|
193
|
+
budget.push({ userId: row.userId, localDay: row.localDay, used });
|
|
194
|
+
}
|
|
195
|
+
return { label, records, counts: countsOf(records, events, night), budget };
|
|
196
|
+
}
|
|
197
|
+
/** Every candidate is sent. What a product does before this library is installed. */
|
|
198
|
+
function runBaseline(label, events, send, night) {
|
|
199
|
+
const records = events.map((e) => ({
|
|
200
|
+
candidateId: e.candidate.id,
|
|
201
|
+
userId: e.user.id,
|
|
202
|
+
at: iso(e.now ?? new Date(0)),
|
|
203
|
+
attempt: 1,
|
|
204
|
+
outcome: send() ? "sent" : "spentNotDelivered",
|
|
205
|
+
}));
|
|
206
|
+
return { label, records, counts: countsOf(records, events, night), budget: [] };
|
|
207
|
+
}
|
|
208
|
+
/** The last attempt is what became of a candidate; earlier attempts are how it got there. */
|
|
209
|
+
function finalOf(run, candidateId) {
|
|
210
|
+
const attempts = run.records.filter((r) => r.candidateId === candidateId);
|
|
211
|
+
return attempts.length ? attempts[attempts.length - 1] : undefined;
|
|
212
|
+
}
|
|
213
|
+
export async function simulate(options) {
|
|
214
|
+
const seed = options.seed ?? 1;
|
|
215
|
+
const failureRate = options.transportFailureRate ?? 0;
|
|
216
|
+
const expirySeconds = options.expirySeconds ?? DEFAULT_EXPIRY_SECONDS;
|
|
217
|
+
const night = options.night ?? DEFAULT_NIGHT;
|
|
218
|
+
const { events, policies } = options;
|
|
219
|
+
if (!events.length)
|
|
220
|
+
throw new Error("simulate: no events to run");
|
|
221
|
+
if (!policies.length)
|
|
222
|
+
throw new Error("simulate: no policies to run");
|
|
223
|
+
const runs = [];
|
|
224
|
+
for (const entry of policies) {
|
|
225
|
+
// A transport per run, seeded identically, so one run's failures cannot move another's.
|
|
226
|
+
const random = mulberry32(seed);
|
|
227
|
+
const send = () => random() >= failureRate;
|
|
228
|
+
if (!entry.policy && !entry.checks) {
|
|
229
|
+
runs.push(runBaseline(entry.label, events, send, night));
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const store = new MemoryStore();
|
|
233
|
+
const gate = entry.policy
|
|
234
|
+
? createGate({ policy: entry.policy, store })
|
|
235
|
+
: createGate({ checks: entry.checks ?? [], store });
|
|
236
|
+
runs.push(await runGate(entry.label, gate, store, events, send, expirySeconds, night));
|
|
237
|
+
}
|
|
238
|
+
const timeline = events.map((event) => {
|
|
239
|
+
const at = event.now ?? new Date(0);
|
|
240
|
+
const local = localOf(event.user.timezone, at);
|
|
241
|
+
return {
|
|
242
|
+
candidateId: event.candidate.id,
|
|
243
|
+
userId: event.user.id,
|
|
244
|
+
at: iso(at),
|
|
245
|
+
localTime: `${local.day.slice(5)} ${local.text}`,
|
|
246
|
+
type: event.candidate.type,
|
|
247
|
+
priority: event.candidate.priority ?? "normal",
|
|
248
|
+
cells: runs.map((run) => {
|
|
249
|
+
const record = finalOf(run, event.candidate.id);
|
|
250
|
+
if (!record)
|
|
251
|
+
return { outcome: "held" };
|
|
252
|
+
const check = record.rejectedBy ?? record.deferredBy;
|
|
253
|
+
return {
|
|
254
|
+
outcome: record.outcome,
|
|
255
|
+
...(check ? { check } : {}),
|
|
256
|
+
...(record.sentence ? { sentence: record.sentence } : {}),
|
|
257
|
+
...(record.retryAt ? { retryAt: record.retryAt } : {}),
|
|
258
|
+
};
|
|
259
|
+
}),
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
return {
|
|
263
|
+
seed,
|
|
264
|
+
events: events.length,
|
|
265
|
+
runs,
|
|
266
|
+
timeline,
|
|
267
|
+
disagreements: timeline.filter((row) => new Set(row.cells.map((c) => c.outcome)).size > 1),
|
|
268
|
+
};
|
|
269
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proactive-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Decide whether a proactive AI agent may reach a user right now, and log why not. Ordered checks as code or JSON, a conformance spec, presets for platform and legal limits, adapters for AI SDK, Mastra, LangChain and OpenAI Agents, and a Python sibling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"sideEffects": false,
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "tsc -p tsconfig.json",
|
|
51
|
-
"test": "npm run build && node test/spec-lint.mjs && node --test dist/test/gate.test.js dist/test/conformance.test.js dist/test/presets.test.js dist/test/adapters.test.js dist/test/init.test.js dist/test/properties.test.js dist/test/dedupe.test.js dist/test/explain.test.js test/release.test.mjs test/examples.test.mjs test/example-imports.test.mjs test/naive.test.mjs test/suite.test.mjs",
|
|
51
|
+
"test": "npm run build && node test/spec-lint.mjs && node --test dist/test/gate.test.js dist/test/conformance.test.js dist/test/presets.test.js dist/test/adapters.test.js dist/test/init.test.js dist/test/properties.test.js dist/test/dedupe.test.js dist/test/simulate.test.js dist/test/monotonicity.test.js dist/test/explain.test.js test/release.test.mjs test/examples.test.mjs test/example-imports.test.mjs test/naive.test.mjs test/playground-render.test.mjs test/playground-scenarios.test.mjs test/snippet.test.mjs test/suite.test.mjs",
|
|
52
52
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
53
53
|
"spec-lint": "node test/spec-lint.mjs",
|
|
54
54
|
"conformance": "npm run build && node dist/src/cli.js replay --fixtures spec/fixtures",
|
|
@@ -61,7 +61,8 @@
|
|
|
61
61
|
"bench:compare": "npm run build && node bench/compare.mjs",
|
|
62
62
|
"conformance-table": "node scripts/conformance-table.mjs",
|
|
63
63
|
"explain-parity": "node scripts/explain-parity.mjs",
|
|
64
|
-
"og": "npm run build && node scripts/og-image.mjs"
|
|
64
|
+
"og": "npm run build && node scripts/og-image.mjs",
|
|
65
|
+
"simulate": "npm run build && node dist/src/cli.js simulate"
|
|
65
66
|
},
|
|
66
67
|
"engines": {
|
|
67
68
|
"node": ">=20"
|
package/spec/CONFORMANCE.md
CHANGED
|
@@ -11,9 +11,9 @@ The suite is versioned by `SPEC_VERSION`, and each version is tagged `spec/vX.Y.
|
|
|
11
11
|
separate from the package's own `vX.Y.Z` release tags.
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
git clone --depth 1 --branch spec/v1.
|
|
14
|
+
git clone --depth 1 --branch spec/v1.3.0 https://github.com/Bubblegunn/proactive-gate
|
|
15
15
|
# or, to keep it beside your own source and update it deliberately
|
|
16
|
-
git subtree add --prefix spec https://github.com/Bubblegunn/proactive-gate spec/v1.
|
|
16
|
+
git subtree add --prefix spec https://github.com/Bubblegunn/proactive-gate spec/v1.3.0 --squash
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
A JavaScript implementation can also read the fixtures from an install, because the npm package
|
|
@@ -72,7 +72,9 @@ A fixture is a contract for every implementation, not only this one. So a change
|
|
|
72
72
|
`test/spec-lint.mjs` checks.
|
|
73
73
|
|
|
74
74
|
Versioning follows `SPEC.md`: a patch adds fixtures existing implementations already pass, a minor
|
|
75
|
-
adds a check or a field, a major changes an expectation.
|
|
75
|
+
adds a check or a field, a major changes an expectation. A new preset is a minor for the same
|
|
76
|
+
reason a new check is: 7.3 makes a policy that names an unknown preset a compile error, so nobody
|
|
77
|
+
already passes the fixture that exercises it.
|
|
76
78
|
|
|
77
79
|
## The honest status of this suite
|
|
78
80
|
|
|
@@ -81,3 +83,47 @@ weaker evidence than it looks: agreement between two implementations by one auth
|
|
|
81
83
|
consistency check than to independent verification. A third implementation, written from `SPEC.md`
|
|
82
84
|
by someone who has not read the source, is what would test whether this document is enough. Until
|
|
83
85
|
that exists, treat the suite as a contract that has been used twice, not as a proven standard.
|
|
86
|
+
|
|
87
|
+
## Implementing this in your language
|
|
88
|
+
|
|
89
|
+
This is the one thing the project is actively asking for, and it is deliberately the last section
|
|
90
|
+
of this document: everything above is what you need, and nothing below it is required of you.
|
|
91
|
+
|
|
92
|
+
**The shape of the work.** A conforming implementation is a decision loop over an ordered list of
|
|
93
|
+
checks and a key-value store with a counter. There is no network, no dependency, no framework and
|
|
94
|
+
no content inspection anywhere in it. The TypeScript version is about 1,400 lines of source and the
|
|
95
|
+
Python sibling about the same, and most of that is the twelve checks, each of which is a small pure
|
|
96
|
+
function over a user, a candidate and an instant.
|
|
97
|
+
|
|
98
|
+
**The order to do it in**, which is how both existing versions were built:
|
|
99
|
+
|
|
100
|
+
1. Vendor the suite at a tag rather than a branch: `git clone --depth 1 --branch spec/vX.Y.Z` or a
|
|
101
|
+
subtree, and assert in your tests that the `SPEC_VERSION` you vendored is the one you claim.
|
|
102
|
+
2. Write the fixture loader first. Each file under `fixtures/` is a policy plus a list of cases,
|
|
103
|
+
each case an input and an expectation, and the loader is the only part that touches JSON.
|
|
104
|
+
3. Make one fixture pass, in this order: `consent`, then `quiet-hours/istanbul`, then
|
|
105
|
+
`budget/daily-atomic-commit`. Those three exercise the whole spine: ordering, a wall clock in a
|
|
106
|
+
named zone, and a counter that has to be atomic at commit.
|
|
107
|
+
4. Then the rest, in whatever order the failures suggest. `spec/SPEC.md` is numbered, and every
|
|
108
|
+
fixture names the requirement it came from.
|
|
109
|
+
|
|
110
|
+
**What passing means** is defined above and is not negotiable in one direction: a fixture you do
|
|
111
|
+
not pass is declared in your skip file with a reason, in the open. Silence about a failing fixture
|
|
112
|
+
is the one thing that makes a conformance claim worthless.
|
|
113
|
+
|
|
114
|
+
**What you may leave out.** Presets, adapters, the JSON policy compiler, `explain()`, the CLI and
|
|
115
|
+
the simulator are all optional; the fixtures that exercise them are the ones to declare as skipped.
|
|
116
|
+
The twelve checks, the ordering rules, the store contract and the commit-time budget are not
|
|
117
|
+
optional, because they are what the word conform refers to.
|
|
118
|
+
|
|
119
|
+
**What we will do with it.** If you say it conforms and the fixtures agree, it goes in the README
|
|
120
|
+
next to the other two implementations, under your name, with the version of the spec it targets.
|
|
121
|
+
If it does not conform yet, say which fixtures and it goes in as a work in progress, because an
|
|
122
|
+
honest partial claim is more useful to a reader than an absent one.
|
|
123
|
+
|
|
124
|
+
**What we will not do.** We will not write it for you and then call it independent. The value of a
|
|
125
|
+
third implementation is exactly that its author was not us, so a version written here would be
|
|
126
|
+
worth less than the four days it took somebody else.
|
|
127
|
+
|
|
128
|
+
Issue [#16](https://github.com/Bubblegunn/proactive-gate/issues/16) is the place to say you are
|
|
129
|
+
starting, so two people do not write the same one.
|
package/spec/SPEC.md
CHANGED
|
@@ -7,6 +7,9 @@ empty at a stable release.
|
|
|
7
7
|
|
|
8
8
|
Versioning: patch releases add fixtures that existing implementations already pass; minor
|
|
9
9
|
releases add a check or field and mark it with `since`; major releases change an expectation.
|
|
10
|
+
A preset added to the vocabulary is a minor, because 7.3 requires an implementation to reject a
|
|
11
|
+
policy naming a preset it does not know, so a fixture that names the new preset is one no existing
|
|
12
|
+
implementation can pass.
|
|
10
13
|
An implementation declares the spec version it targets, and its CI MUST assert that the value
|
|
11
14
|
equals `SPEC_VERSION`.
|
|
12
15
|
|
package/spec/SPEC_VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.3.0
|