@bitbaum/ai-kit 1.1.0 → 1.2.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/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/policy.d.ts +127 -0
- package/dist/policy.js +122 -0
- package/package.json +1 -1
- package/src/index.ts +15 -0
- package/src/policy.ts +206 -0
package/dist/index.d.ts
CHANGED
|
@@ -57,4 +57,5 @@ export { type HealthStatus, type Health, type HealthTrackerOptions, type HealthT
|
|
|
57
57
|
export { type LivenessResult, type LivenessOptions, type LivenessProbe, type AiHealthHandlerOptions, createLivenessProbe, createAiHealthHandler, } from "./liveness.js";
|
|
58
58
|
export { type RateLimitKind, classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
|
|
59
59
|
export { type QuotaScope, type QuotaWindow, type QuotaReading, type HeaderBag, readQuota, readingFromRefusal, parseResetAt, answersRemaining, } from "./meter.js";
|
|
60
|
+
export { type PoolId, type RungId, type TierPolicy, type AiPolicy, type UserState, type WallOption, type Wall, type Decision, DEFAULT_LADDER, decide, shouldSurface, nextUtcReset, } from "./policy.js";
|
|
60
61
|
export { DAY_SECONDS, DEFAULT_BURST, type ShareInput, type ShareReason, type ShareDecision, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
|
package/dist/index.js
CHANGED
|
@@ -57,6 +57,7 @@ export { createHealthTracker, } from "./health.js";
|
|
|
57
57
|
export { createLivenessProbe, createAiHealthHandler, } from "./liveness.js";
|
|
58
58
|
export { classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
|
|
59
59
|
export { readQuota, readingFromRefusal, parseResetAt, answersRemaining, } from "./meter.js";
|
|
60
|
+
export { DEFAULT_LADDER, decide, shouldSurface, nextUtcReset, } from "./policy.js";
|
|
60
61
|
export { DAY_SECONDS, DEFAULT_BURST, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
|
|
61
62
|
// Form filling lives at `ai-kit/forms`, NOT here.
|
|
62
63
|
//
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may spend what, and what to offer when they may not.
|
|
3
|
+
*
|
|
4
|
+
* Pure by the same rule as `fair-share`: no database, no clock, no provider.
|
|
5
|
+
* The caller owns "what has this user done"; this file owns "what are they
|
|
6
|
+
* entitled to, and what should they be shown next".
|
|
7
|
+
*
|
|
8
|
+
* ── WHY THIS IS ONE OBJECT AND NOT TWO ───────────────────────────────────────
|
|
9
|
+
*
|
|
10
|
+
* Across this fleet the same feature was built twice, half each. One app
|
|
11
|
+
* enforces a daily budget with no interface at all — the user is refused and
|
|
12
|
+
* never learns why, or what their share was. Another shows a quota meter with
|
|
13
|
+
* its own separate notion of tier, which the enforcement path does not read.
|
|
14
|
+
* Both are correct in isolation and they cannot agree, because there was no
|
|
15
|
+
* single statement of the policy for them to agree ABOUT.
|
|
16
|
+
*
|
|
17
|
+
* So the policy is data, and the same object answers both questions: the gate
|
|
18
|
+
* asks `decide()` whether to allow, and the settings page asks `decide()` what
|
|
19
|
+
* to draw. A number on screen that the gate does not use is decoration; a gate
|
|
20
|
+
* whose reasoning cannot be rendered is a wall.
|
|
21
|
+
*
|
|
22
|
+
* ── WHY FREE CAPACITY IS A LADDER AND NOT A LIMIT ────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* Shared free capacity is a fixed cost carried on behalf of strangers, and its
|
|
25
|
+
* only job is to be good enough, once, that someone wants more of it. The
|
|
26
|
+
* moment it runs out is therefore the only moment anybody changes anything —
|
|
27
|
+
* which makes the refusal the most important screen in the product, not an
|
|
28
|
+
* error state.
|
|
29
|
+
*
|
|
30
|
+
* Hence `Wall`: a refusal always carries the ways out, in order, and the
|
|
31
|
+
* default order puts the exits that cost the user NOTHING ahead of the one that
|
|
32
|
+
* costs money. Asking someone to make a free account elsewhere converts far
|
|
33
|
+
* better than asking them to pay, and it removes them from the shared pool just
|
|
34
|
+
* as completely.
|
|
35
|
+
*
|
|
36
|
+
* And there is always a floor. `wait` is a real rung with a real time on it,
|
|
37
|
+
* because "come back at 01:00" is a path and "rate limit exceeded" is a dead
|
|
38
|
+
* end that reads as broken.
|
|
39
|
+
*/
|
|
40
|
+
/** Where the capacity for a turn comes from. */
|
|
41
|
+
export type PoolId =
|
|
42
|
+
/** Your keys, your cost, shared by everyone. The sample. */
|
|
43
|
+
"platform"
|
|
44
|
+
/** The user's own key. Free to you, uncapped for them. */
|
|
45
|
+
| "user"
|
|
46
|
+
/** A model on the user's machine. Free to everyone. */
|
|
47
|
+
| "local";
|
|
48
|
+
/** The ways off the shared pool, and the floor beneath them. */
|
|
49
|
+
export type RungId = "byok" | "local" | "earn" | "paid" | "wait";
|
|
50
|
+
export interface TierPolicy {
|
|
51
|
+
/** Turns per UTC day. `null` means uncapped — the user is not your cost. */
|
|
52
|
+
turnsPerDay: number | null;
|
|
53
|
+
pool: PoolId;
|
|
54
|
+
/** Present when spending is metered against a balance rather than a count. */
|
|
55
|
+
meter?: "credits";
|
|
56
|
+
}
|
|
57
|
+
export interface AiPolicy {
|
|
58
|
+
/** Tier id → what it may do. App-defined; only the shape is fixed here. */
|
|
59
|
+
tiers: Record<string, TierPolicy>;
|
|
60
|
+
/**
|
|
61
|
+
* What to offer at a wall, in order.
|
|
62
|
+
*
|
|
63
|
+
* Defaults to free exits first. Overriding it to lead with `paid` is a
|
|
64
|
+
* product decision this package will not make for you, but it is worth
|
|
65
|
+
* knowing you are then asking for money from someone who has not yet
|
|
66
|
+
* finished evaluating the thing.
|
|
67
|
+
*/
|
|
68
|
+
ladder?: RungId[];
|
|
69
|
+
}
|
|
70
|
+
/** Sensible order: everything that costs the user nothing, then money, then the floor. */
|
|
71
|
+
export declare const DEFAULT_LADDER: RungId[];
|
|
72
|
+
/** What the caller knows about this user right now. */
|
|
73
|
+
export interface UserState {
|
|
74
|
+
/** Which tier they are on — a key of `policy.tiers`. */
|
|
75
|
+
tier: string;
|
|
76
|
+
/** Turns already spent this UTC day. */
|
|
77
|
+
spentToday: number;
|
|
78
|
+
/** Credit balance, when the tier meters credits. */
|
|
79
|
+
credits?: number;
|
|
80
|
+
/** Rungs this user could actually take. A rung they cannot reach is not offered. */
|
|
81
|
+
available?: Partial<Record<RungId, boolean>>;
|
|
82
|
+
}
|
|
83
|
+
/** One way out of a wall, ready to render. */
|
|
84
|
+
export interface WallOption {
|
|
85
|
+
rung: RungId;
|
|
86
|
+
/** Whether this user can actually take it. */
|
|
87
|
+
available: boolean;
|
|
88
|
+
}
|
|
89
|
+
/** Everything needed to draw a refusal that is not a dead end. */
|
|
90
|
+
export interface Wall {
|
|
91
|
+
reason: "day-spent" | "no-credits" | "unknown-tier";
|
|
92
|
+
/** Ordered, best first. Never empty — `wait` is always last. */
|
|
93
|
+
options: WallOption[];
|
|
94
|
+
/** Epoch ms when the allowance refills, when the caller supplied a day boundary. */
|
|
95
|
+
resetAt: number | null;
|
|
96
|
+
}
|
|
97
|
+
export interface Decision {
|
|
98
|
+
allowed: boolean;
|
|
99
|
+
/** The resolved tier policy, or null when the tier is not in the policy. */
|
|
100
|
+
policy: TierPolicy | null;
|
|
101
|
+
pool: PoolId | null;
|
|
102
|
+
/**
|
|
103
|
+
* Turns left today. `null` means uncapped, which is NOT the same as zero and
|
|
104
|
+
* must not render as a gauge — there is nothing to draw a level against.
|
|
105
|
+
*/
|
|
106
|
+
remaining: number | null;
|
|
107
|
+
/** Present only when `allowed` is false. */
|
|
108
|
+
wall?: Wall;
|
|
109
|
+
}
|
|
110
|
+
/** Next UTC midnight — when a per-day allowance refills. */
|
|
111
|
+
export declare function nextUtcReset(now?: number): number;
|
|
112
|
+
/**
|
|
113
|
+
* May this user spend a turn, and if not, what should they be offered?
|
|
114
|
+
*
|
|
115
|
+
* The same call answers the gate and the settings page. A caller that only
|
|
116
|
+
* needs the number reads `remaining`; one drawing a refusal reads `wall`.
|
|
117
|
+
*/
|
|
118
|
+
export declare function decide(policy: AiPolicy, state: UserState, now?: number): Decision;
|
|
119
|
+
/**
|
|
120
|
+
* Should the remaining count be put in front of the user yet?
|
|
121
|
+
*
|
|
122
|
+
* A permanent gauge at 96% trains people to ignore the one at 4%. The indicator
|
|
123
|
+
* earns attention near the edge and stays quiet before it — so this returns
|
|
124
|
+
* false for an uncapped tier (nothing to say) and for a user who has barely
|
|
125
|
+
* started.
|
|
126
|
+
*/
|
|
127
|
+
export declare function shouldSurface(decision: Decision, threshold?: number): boolean;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may spend what, and what to offer when they may not.
|
|
3
|
+
*
|
|
4
|
+
* Pure by the same rule as `fair-share`: no database, no clock, no provider.
|
|
5
|
+
* The caller owns "what has this user done"; this file owns "what are they
|
|
6
|
+
* entitled to, and what should they be shown next".
|
|
7
|
+
*
|
|
8
|
+
* ── WHY THIS IS ONE OBJECT AND NOT TWO ───────────────────────────────────────
|
|
9
|
+
*
|
|
10
|
+
* Across this fleet the same feature was built twice, half each. One app
|
|
11
|
+
* enforces a daily budget with no interface at all — the user is refused and
|
|
12
|
+
* never learns why, or what their share was. Another shows a quota meter with
|
|
13
|
+
* its own separate notion of tier, which the enforcement path does not read.
|
|
14
|
+
* Both are correct in isolation and they cannot agree, because there was no
|
|
15
|
+
* single statement of the policy for them to agree ABOUT.
|
|
16
|
+
*
|
|
17
|
+
* So the policy is data, and the same object answers both questions: the gate
|
|
18
|
+
* asks `decide()` whether to allow, and the settings page asks `decide()` what
|
|
19
|
+
* to draw. A number on screen that the gate does not use is decoration; a gate
|
|
20
|
+
* whose reasoning cannot be rendered is a wall.
|
|
21
|
+
*
|
|
22
|
+
* ── WHY FREE CAPACITY IS A LADDER AND NOT A LIMIT ────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* Shared free capacity is a fixed cost carried on behalf of strangers, and its
|
|
25
|
+
* only job is to be good enough, once, that someone wants more of it. The
|
|
26
|
+
* moment it runs out is therefore the only moment anybody changes anything —
|
|
27
|
+
* which makes the refusal the most important screen in the product, not an
|
|
28
|
+
* error state.
|
|
29
|
+
*
|
|
30
|
+
* Hence `Wall`: a refusal always carries the ways out, in order, and the
|
|
31
|
+
* default order puts the exits that cost the user NOTHING ahead of the one that
|
|
32
|
+
* costs money. Asking someone to make a free account elsewhere converts far
|
|
33
|
+
* better than asking them to pay, and it removes them from the shared pool just
|
|
34
|
+
* as completely.
|
|
35
|
+
*
|
|
36
|
+
* And there is always a floor. `wait` is a real rung with a real time on it,
|
|
37
|
+
* because "come back at 01:00" is a path and "rate limit exceeded" is a dead
|
|
38
|
+
* end that reads as broken.
|
|
39
|
+
*/
|
|
40
|
+
/** Sensible order: everything that costs the user nothing, then money, then the floor. */
|
|
41
|
+
export const DEFAULT_LADDER = ["byok", "local", "earn", "paid", "wait"];
|
|
42
|
+
/** Next UTC midnight — when a per-day allowance refills. */
|
|
43
|
+
export function nextUtcReset(now = Date.now()) {
|
|
44
|
+
const d = new Date(now);
|
|
45
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
|
|
46
|
+
}
|
|
47
|
+
function buildWall(reason, policy, state, now) {
|
|
48
|
+
const order = policy.ladder ?? DEFAULT_LADDER;
|
|
49
|
+
const options = order
|
|
50
|
+
// `wait` is appended below so it can never be configured away.
|
|
51
|
+
.filter((rung) => rung !== "wait")
|
|
52
|
+
.map((rung) => ({ rung, available: state.available?.[rung] ?? true }));
|
|
53
|
+
// The floor. A refusal without a way forward reads as a broken product, and
|
|
54
|
+
// the honest way forward is always "this refills, here is when".
|
|
55
|
+
options.push({ rung: "wait", available: true });
|
|
56
|
+
return { reason, options, resetAt: nextUtcReset(now) };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* May this user spend a turn, and if not, what should they be offered?
|
|
60
|
+
*
|
|
61
|
+
* The same call answers the gate and the settings page. A caller that only
|
|
62
|
+
* needs the number reads `remaining`; one drawing a refusal reads `wall`.
|
|
63
|
+
*/
|
|
64
|
+
export function decide(policy, state, now = Date.now()) {
|
|
65
|
+
const tier = policy.tiers[state.tier];
|
|
66
|
+
// An unknown tier is not a licence to spend. It is a configuration error, and
|
|
67
|
+
// failing closed here is the difference between a typo costing nothing and a
|
|
68
|
+
// typo handing out the shared pool.
|
|
69
|
+
if (!tier) {
|
|
70
|
+
return {
|
|
71
|
+
allowed: false,
|
|
72
|
+
policy: null,
|
|
73
|
+
pool: null,
|
|
74
|
+
remaining: 0,
|
|
75
|
+
wall: buildWall("unknown-tier", policy, state, now),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (tier.meter === "credits") {
|
|
79
|
+
const credits = state.credits ?? 0;
|
|
80
|
+
return credits > 0
|
|
81
|
+
? { allowed: true, policy: tier, pool: tier.pool, remaining: null }
|
|
82
|
+
: {
|
|
83
|
+
allowed: false,
|
|
84
|
+
policy: tier,
|
|
85
|
+
pool: tier.pool,
|
|
86
|
+
remaining: 0,
|
|
87
|
+
wall: buildWall("no-credits", policy, state, now),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// Uncapped: the user is not your cost, so there is no number to police.
|
|
91
|
+
if (tier.turnsPerDay === null) {
|
|
92
|
+
return { allowed: true, policy: tier, pool: tier.pool, remaining: null };
|
|
93
|
+
}
|
|
94
|
+
const remaining = Math.max(0, tier.turnsPerDay - state.spentToday);
|
|
95
|
+
return remaining > 0
|
|
96
|
+
? { allowed: true, policy: tier, pool: tier.pool, remaining }
|
|
97
|
+
: {
|
|
98
|
+
allowed: false,
|
|
99
|
+
policy: tier,
|
|
100
|
+
pool: tier.pool,
|
|
101
|
+
remaining: 0,
|
|
102
|
+
wall: buildWall("day-spent", policy, state, now),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Should the remaining count be put in front of the user yet?
|
|
107
|
+
*
|
|
108
|
+
* A permanent gauge at 96% trains people to ignore the one at 4%. The indicator
|
|
109
|
+
* earns attention near the edge and stays quiet before it — so this returns
|
|
110
|
+
* false for an uncapped tier (nothing to say) and for a user who has barely
|
|
111
|
+
* started.
|
|
112
|
+
*/
|
|
113
|
+
export function shouldSurface(decision, threshold = 0.34) {
|
|
114
|
+
if (decision.remaining === null)
|
|
115
|
+
return false;
|
|
116
|
+
if (!decision.allowed)
|
|
117
|
+
return true;
|
|
118
|
+
const cap = decision.policy?.turnsPerDay;
|
|
119
|
+
if (!cap)
|
|
120
|
+
return false;
|
|
121
|
+
return decision.remaining / cap <= threshold;
|
|
122
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitbaum/ai-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "One install for the AI layer of an app: which model to call, what to do when the vendor retires it, how to walk the fallback chain and know when none of it worked, how to read the three kinds of 429, a fair daily budget across users, headless AI form filling \u2014 and now the model registry (one SSOT for every callable id, with the paid/free boundary as a field) and the grounding harness (facts, contract, deterministic fabrication check).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Mao Nakamoto",
|
package/src/index.ts
CHANGED
|
@@ -128,6 +128,21 @@ export {
|
|
|
128
128
|
answersRemaining,
|
|
129
129
|
} from "./meter.js";
|
|
130
130
|
|
|
131
|
+
export {
|
|
132
|
+
type PoolId,
|
|
133
|
+
type RungId,
|
|
134
|
+
type TierPolicy,
|
|
135
|
+
type AiPolicy,
|
|
136
|
+
type UserState,
|
|
137
|
+
type WallOption,
|
|
138
|
+
type Wall,
|
|
139
|
+
type Decision,
|
|
140
|
+
DEFAULT_LADDER,
|
|
141
|
+
decide,
|
|
142
|
+
shouldSurface,
|
|
143
|
+
nextUtcReset,
|
|
144
|
+
} from "./policy.js";
|
|
145
|
+
|
|
131
146
|
export {
|
|
132
147
|
DAY_SECONDS,
|
|
133
148
|
DEFAULT_BURST,
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may spend what, and what to offer when they may not.
|
|
3
|
+
*
|
|
4
|
+
* Pure by the same rule as `fair-share`: no database, no clock, no provider.
|
|
5
|
+
* The caller owns "what has this user done"; this file owns "what are they
|
|
6
|
+
* entitled to, and what should they be shown next".
|
|
7
|
+
*
|
|
8
|
+
* ── WHY THIS IS ONE OBJECT AND NOT TWO ───────────────────────────────────────
|
|
9
|
+
*
|
|
10
|
+
* Across this fleet the same feature was built twice, half each. One app
|
|
11
|
+
* enforces a daily budget with no interface at all — the user is refused and
|
|
12
|
+
* never learns why, or what their share was. Another shows a quota meter with
|
|
13
|
+
* its own separate notion of tier, which the enforcement path does not read.
|
|
14
|
+
* Both are correct in isolation and they cannot agree, because there was no
|
|
15
|
+
* single statement of the policy for them to agree ABOUT.
|
|
16
|
+
*
|
|
17
|
+
* So the policy is data, and the same object answers both questions: the gate
|
|
18
|
+
* asks `decide()` whether to allow, and the settings page asks `decide()` what
|
|
19
|
+
* to draw. A number on screen that the gate does not use is decoration; a gate
|
|
20
|
+
* whose reasoning cannot be rendered is a wall.
|
|
21
|
+
*
|
|
22
|
+
* ── WHY FREE CAPACITY IS A LADDER AND NOT A LIMIT ────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* Shared free capacity is a fixed cost carried on behalf of strangers, and its
|
|
25
|
+
* only job is to be good enough, once, that someone wants more of it. The
|
|
26
|
+
* moment it runs out is therefore the only moment anybody changes anything —
|
|
27
|
+
* which makes the refusal the most important screen in the product, not an
|
|
28
|
+
* error state.
|
|
29
|
+
*
|
|
30
|
+
* Hence `Wall`: a refusal always carries the ways out, in order, and the
|
|
31
|
+
* default order puts the exits that cost the user NOTHING ahead of the one that
|
|
32
|
+
* costs money. Asking someone to make a free account elsewhere converts far
|
|
33
|
+
* better than asking them to pay, and it removes them from the shared pool just
|
|
34
|
+
* as completely.
|
|
35
|
+
*
|
|
36
|
+
* And there is always a floor. `wait` is a real rung with a real time on it,
|
|
37
|
+
* because "come back at 01:00" is a path and "rate limit exceeded" is a dead
|
|
38
|
+
* end that reads as broken.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Where the capacity for a turn comes from. */
|
|
42
|
+
export type PoolId =
|
|
43
|
+
/** Your keys, your cost, shared by everyone. The sample. */
|
|
44
|
+
| "platform"
|
|
45
|
+
/** The user's own key. Free to you, uncapped for them. */
|
|
46
|
+
| "user"
|
|
47
|
+
/** A model on the user's machine. Free to everyone. */
|
|
48
|
+
| "local";
|
|
49
|
+
|
|
50
|
+
/** The ways off the shared pool, and the floor beneath them. */
|
|
51
|
+
export type RungId = "byok" | "local" | "earn" | "paid" | "wait";
|
|
52
|
+
|
|
53
|
+
export interface TierPolicy {
|
|
54
|
+
/** Turns per UTC day. `null` means uncapped — the user is not your cost. */
|
|
55
|
+
turnsPerDay: number | null;
|
|
56
|
+
pool: PoolId;
|
|
57
|
+
/** Present when spending is metered against a balance rather than a count. */
|
|
58
|
+
meter?: "credits";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AiPolicy {
|
|
62
|
+
/** Tier id → what it may do. App-defined; only the shape is fixed here. */
|
|
63
|
+
tiers: Record<string, TierPolicy>;
|
|
64
|
+
/**
|
|
65
|
+
* What to offer at a wall, in order.
|
|
66
|
+
*
|
|
67
|
+
* Defaults to free exits first. Overriding it to lead with `paid` is a
|
|
68
|
+
* product decision this package will not make for you, but it is worth
|
|
69
|
+
* knowing you are then asking for money from someone who has not yet
|
|
70
|
+
* finished evaluating the thing.
|
|
71
|
+
*/
|
|
72
|
+
ladder?: RungId[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Sensible order: everything that costs the user nothing, then money, then the floor. */
|
|
76
|
+
export const DEFAULT_LADDER: RungId[] = ["byok", "local", "earn", "paid", "wait"];
|
|
77
|
+
|
|
78
|
+
/** What the caller knows about this user right now. */
|
|
79
|
+
export interface UserState {
|
|
80
|
+
/** Which tier they are on — a key of `policy.tiers`. */
|
|
81
|
+
tier: string;
|
|
82
|
+
/** Turns already spent this UTC day. */
|
|
83
|
+
spentToday: number;
|
|
84
|
+
/** Credit balance, when the tier meters credits. */
|
|
85
|
+
credits?: number;
|
|
86
|
+
/** Rungs this user could actually take. A rung they cannot reach is not offered. */
|
|
87
|
+
available?: Partial<Record<RungId, boolean>>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One way out of a wall, ready to render. */
|
|
91
|
+
export interface WallOption {
|
|
92
|
+
rung: RungId;
|
|
93
|
+
/** Whether this user can actually take it. */
|
|
94
|
+
available: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Everything needed to draw a refusal that is not a dead end. */
|
|
98
|
+
export interface Wall {
|
|
99
|
+
reason: "day-spent" | "no-credits" | "unknown-tier";
|
|
100
|
+
/** Ordered, best first. Never empty — `wait` is always last. */
|
|
101
|
+
options: WallOption[];
|
|
102
|
+
/** Epoch ms when the allowance refills, when the caller supplied a day boundary. */
|
|
103
|
+
resetAt: number | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface Decision {
|
|
107
|
+
allowed: boolean;
|
|
108
|
+
/** The resolved tier policy, or null when the tier is not in the policy. */
|
|
109
|
+
policy: TierPolicy | null;
|
|
110
|
+
pool: PoolId | null;
|
|
111
|
+
/**
|
|
112
|
+
* Turns left today. `null` means uncapped, which is NOT the same as zero and
|
|
113
|
+
* must not render as a gauge — there is nothing to draw a level against.
|
|
114
|
+
*/
|
|
115
|
+
remaining: number | null;
|
|
116
|
+
/** Present only when `allowed` is false. */
|
|
117
|
+
wall?: Wall;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Next UTC midnight — when a per-day allowance refills. */
|
|
121
|
+
export function nextUtcReset(now = Date.now()): number {
|
|
122
|
+
const d = new Date(now);
|
|
123
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function buildWall(reason: Wall["reason"], policy: AiPolicy, state: UserState, now: number): Wall {
|
|
127
|
+
const order = policy.ladder ?? DEFAULT_LADDER;
|
|
128
|
+
const options: WallOption[] = order
|
|
129
|
+
// `wait` is appended below so it can never be configured away.
|
|
130
|
+
.filter((rung) => rung !== "wait")
|
|
131
|
+
.map((rung) => ({ rung, available: state.available?.[rung] ?? true }));
|
|
132
|
+
|
|
133
|
+
// The floor. A refusal without a way forward reads as a broken product, and
|
|
134
|
+
// the honest way forward is always "this refills, here is when".
|
|
135
|
+
options.push({ rung: "wait", available: true });
|
|
136
|
+
|
|
137
|
+
return { reason, options, resetAt: nextUtcReset(now) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* May this user spend a turn, and if not, what should they be offered?
|
|
142
|
+
*
|
|
143
|
+
* The same call answers the gate and the settings page. A caller that only
|
|
144
|
+
* needs the number reads `remaining`; one drawing a refusal reads `wall`.
|
|
145
|
+
*/
|
|
146
|
+
export function decide(policy: AiPolicy, state: UserState, now = Date.now()): Decision {
|
|
147
|
+
const tier = policy.tiers[state.tier];
|
|
148
|
+
|
|
149
|
+
// An unknown tier is not a licence to spend. It is a configuration error, and
|
|
150
|
+
// failing closed here is the difference between a typo costing nothing and a
|
|
151
|
+
// typo handing out the shared pool.
|
|
152
|
+
if (!tier) {
|
|
153
|
+
return {
|
|
154
|
+
allowed: false,
|
|
155
|
+
policy: null,
|
|
156
|
+
pool: null,
|
|
157
|
+
remaining: 0,
|
|
158
|
+
wall: buildWall("unknown-tier", policy, state, now),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (tier.meter === "credits") {
|
|
163
|
+
const credits = state.credits ?? 0;
|
|
164
|
+
return credits > 0
|
|
165
|
+
? { allowed: true, policy: tier, pool: tier.pool, remaining: null }
|
|
166
|
+
: {
|
|
167
|
+
allowed: false,
|
|
168
|
+
policy: tier,
|
|
169
|
+
pool: tier.pool,
|
|
170
|
+
remaining: 0,
|
|
171
|
+
wall: buildWall("no-credits", policy, state, now),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Uncapped: the user is not your cost, so there is no number to police.
|
|
176
|
+
if (tier.turnsPerDay === null) {
|
|
177
|
+
return { allowed: true, policy: tier, pool: tier.pool, remaining: null };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const remaining = Math.max(0, tier.turnsPerDay - state.spentToday);
|
|
181
|
+
return remaining > 0
|
|
182
|
+
? { allowed: true, policy: tier, pool: tier.pool, remaining }
|
|
183
|
+
: {
|
|
184
|
+
allowed: false,
|
|
185
|
+
policy: tier,
|
|
186
|
+
pool: tier.pool,
|
|
187
|
+
remaining: 0,
|
|
188
|
+
wall: buildWall("day-spent", policy, state, now),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Should the remaining count be put in front of the user yet?
|
|
194
|
+
*
|
|
195
|
+
* A permanent gauge at 96% trains people to ignore the one at 4%. The indicator
|
|
196
|
+
* earns attention near the edge and stays quiet before it — so this returns
|
|
197
|
+
* false for an uncapped tier (nothing to say) and for a user who has barely
|
|
198
|
+
* started.
|
|
199
|
+
*/
|
|
200
|
+
export function shouldSurface(decision: Decision, threshold = 0.34): boolean {
|
|
201
|
+
if (decision.remaining === null) return false;
|
|
202
|
+
if (!decision.allowed) return true;
|
|
203
|
+
const cap = decision.policy?.turnsPerDay;
|
|
204
|
+
if (!cap) return false;
|
|
205
|
+
return decision.remaining / cap <= threshold;
|
|
206
|
+
}
|