mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* feat(schema): op_checkpoints table + doctor_run_id partial GIN (v67+v68) T1 of brain-health-100 wave. Two new migrations underpin autonomous remediation via Minions: - v67 op_checkpoints — shared checkpoint table for long-running ops (embed, extract, lint, backlinks, reindex, integrity). Pre-fix each op had its own file-backed checkpoint or none. PRIMARY KEY (op, fingerprint) lets `extract links` and `extract timeline` (or `reindex --markdown` vs `--code`) coexist without colliding on shared keys. - v68 minion_jobs_doctor_run_id_idx — partial GIN on `minion_jobs.data WHERE data ? 'doctor_run_id'`. Indexes only doctor-submitted jobs so audit-trail queries don't sequential-scan months of unrelated cron history. PGLite skips via empty sqlFor. Applied to src/schema.sql + src/core/pglite-schema.ts so both engines get the table on fresh-install. Bootstrap coverage test + 122-case migrate test both pass. Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (D12 + folded scope B from outside-voice review). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(core): op-checkpoint module — DB-backed checkpoint primitive T2 of brain-health-100 wave. Six exports plus per-op fingerprint helpers: loadOpCheckpoint(engine, key) → string[] (completed keys; [] if none) recordCompleted(engine, key, ks) → void (UPSERT atomic) clearOpCheckpoint(engine, key) → void (clean-exit drop) resumeFilter(all, completed) → string[] (pure; drives batched walks) purgeStaleCheckpoints(engine, ttl)→ number (cycle purge phase consumer) Fingerprint helpers: fingerprint(params) — sha8 of canonical-JSON embedFingerprint(p) — model+dim+slug+source variation extractFingerprint(p) — mode (links vs timeline) reindexFingerprint(p) — markdown vs code vs slug + chunker_version lintFingerprint, backlinksFingerprint, integrityFingerprint, importFingerprint Canonical-JSON over keys-sorted ensures the same params produce the same fingerprint across runs and hosts. sha8 (8 hex chars from sha256) is short enough for filenames + UI but collision-resistant for the expected per-op invocation diversity. DB-backed for both engines (PGLite has the table too via v67). Lost- write on partial DB failure is non-fatal — caller continues, next run re-walks (cheap for hash-short-circuited ops like embed/import). Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (D12 + codex #10–16 from outside-voice review). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(core): brain-score-recommendations — shared data layer T4 of brain-health-100 wave. Pure module — no engine I/O. Takes a BrainHealth snapshot + RecommendationContext, returns ordered Remediation[] ready to feed the doctor remediation plan OR features --auto-fix. Three public exports: computeRecommendations(health, ctx) → Remediation[] classifyChecks(checks, ctx) → CheckClassification[] maxReachableScore(health, classes) → number (0-100 ceiling) D13 — three-state classification per check: remediable / human_only / blocked. The plan ONLY emits remediable items; blocked surfaces alongside as informational with the missing prereq (no API key, etc.). Closes the spin-loop bug on empty / API-key-missing brains (codex #20). D14 — every Remediation has a stable string id (sync.repo, embed.stale, backlinks.fix, extract.all). depends_on references ids, not check names. D9 — idempotency_key is content-hash from canonical-JSON of params. Same intent across runs = same key; failed-row replay via :r<N> suffix is the --remediate loop's job, not this module's. Scope item +A (cost-budget gate) — Remediation.est_usd_cost populated for embed (chars × pricePerMTok from embedding-pricing.ts) and Anthropic jobs (estimateAnthropicCost helper). doctor --remediate --max-usd N gates submission against est_total_usd_cost. Both consumers (doctor + features per D15) import from here. Features executes inline (D15 contract preserved), doctor submits via queue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(handlers): 11 new Minion handlers + 3 added to PROTECTED + sync noExtract fix T5 of brain-health-100 wave. PROTECTED_JOB_NAMES extension (D11): synthesize, patterns, consolidate. These cycle phases internally submit `subagent` jobs with allowProtectedSubmit=true, so they CAN spend Anthropic credits. Treating them as "data-quality maintenance" was a misread surfaced by the codex outside-voice review (#6). Protected gate ensures only trusted local callers (CLI, autopilot, doctor --remediate) can submit; an OAuth-scoped MCP client can't burn the user's API budget by submitting a synthesize job over HTTP. 11 new handlers registered in jobs.ts registerBuiltinHandlers: PROTECTED (3) — phase-wrappers that spawn subagent children: synthesize, patterns, consolidate Open (8) — DB/fs writes only, no LLM spend: reindex, repair-jsonb, orphans, integrity, purge, extract_facts, resolve_symbol_edges, recompute_emotional_weight Phase-wrappers all delegate to `runCycle({ phases: [name] })` rather than extracting standalone phase functions. Cycle.ts already owns the lock + abort signal + progress reporter per D10, so the wrapper is a one-liner and cycle.ts remains the single source of truth for phase semantics. Pragmatic deviation from the plan's "extract 6 standalone runXxxPhase functions" — smaller diff, equivalent correctness. Standalone `sync` handler now passes `noExtract: true` (codex #5 fix). Pre-fix, doctor's remediation plan emitting [sync, extract] caused double-extraction (performSync inline-extract + standalone extract job). Now sync defers extract to the dedicated handler. Callers that want inline extract pass { noExtract: false } in job params. Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T5 + D10 + D11 + codex #5/#6 from outside-voice review). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(doctor): --remediation-plan + --remediate CLI surfaces T6 of brain-health-100 wave. The headline user-facing capability: agents drive brain health to target score via autonomous Minions remediation. Two new flags on `gbrain doctor`: --remediation-plan [--json] [--target-score N] Read-only. Emits ordered Remediation[] from BrainHealth + context. Uses cheap path (D7) — engine.getHealth() + computeRecommendations, NOT a full doctor walk. JSON shape is stable agent contract. --remediate [--yes] [--target-score N] [--max-jobs N] [--max-usd N] [--dry-run] [--json] Sequential submit (D3) with D5 cascade on failure, D7 scoped recheck between steps, D9 content-hash idempotency keys, D13 three-state remediation filtering (only remediable jobs enter the loop), +A cost-budget gate via --max-usd. Check.remediation field added as additive optional (DoctorReport schema_version stays at 2 per D4). PGLite path: synchronous in-process execution with short polling. Postgres path: durable queue submission with waitForCompletion. The --remediate loop: 1. Compute initial plan from BrainHealth 2. Refuse if --target-score > maxReachableScore(health, classes) 3. Refuse if est_total_usd_cost > --max-usd 4. For each step in order: - Skip if depends_on intersects aborted set (D5) - queue.add with content-hash idempotency_key (D9) - waitForCompletion with timeout - Recompute plan from fresh health (D7 scoped recheck) 5. Exit 0 if all completed; 1 if any failed/aborted doctor_run_id UUID stamps every submitted job's data field so operators can later query `SELECT * FROM minion_jobs WHERE data->>'doctor_run_id' = '<uuid>'` (indexed via v68 partial GIN). Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T6 + D1/D3/D5/D7/D9/D13 + folded scope A). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): maybeBackground helper + apply --background to embed T7 of brain-health-100 wave. New helper in src/core/cli-options.ts formalizes the --background flag pattern. Same semantics in TTY and cron per D9 (submit-and-exit always; --background --follow execs `gbrain jobs follow <id>` after submission). await maybeBackground({ engine, args, jobName: 'embed', paramBuilder: (cleanArgs) => ({ stale, all, ... }), }) // returns true if backgrounded → caller exits Content-hash idempotency key (D9): `cli:embed:sha8(canonical-JSON(params))`. No time-slot. Same intent across runs = same key. Failed-row replay is the doctor --remediate loop's job, not this path's. PGLite degrades to inline execution with a clear stderr note ("PGLite has no worker daemon; running inline"). NOT a no-op, NOT silent — doc-stated semantic difference because PGLite has no worker daemon. Applied to `gbrain embed` as the reference integration. The other 6 commands (extract, lint, backlinks, reindex, integrity, pages) adopt the same 4-line pattern at the top of their entry function — follow-up in a smaller diff once the helper proves out in production. Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T7 + D9 + Gap 6). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(autopilot): targeted-submit loop + op_checkpoints GC in purge phase T8 of brain-health-100 wave. Autopilot dispatch changes (src/commands/autopilot.ts): Pre-fix: every tick submitted ONE autopilot-cycle job, full phase set, regardless of brain state. On a healthy brain pure overhead; on a degraded brain bundled fast wins with slow phases so user waited for the slowest. New decision logic (T8 from plan): - score >= 95 AND empty plan AND <60min since last full → SLEEP - score >= 95 AND empty plan AND >=60min → submit autopilot-cycle (phase-coupling exercise) - plan <= 3 steps AND est_total < 5min → submit individual handlers (targeted; uses D9 content-hash idempotency keys per step; maxWaiting:1 per submit per codex #17) - else → submit autopilot-cycle (the hammer) D10 cycle-lock invariant guarantees targeted-submit and autopilot-cycle can never run concurrently (both acquire gbrain-cycle), closing the "60-min floor double-processes queued targeted jobs" failure mode. Computation uses cheap path (D7) — engine.getHealth() + computeRecommendations, NOT a full doctor walk. Adds ~1 SQL count query per tick; negligible on a 50K-page brain. PROTECTED handlers (synthesize/patterns/consolidate) are submitted with allowProtectedSubmit:true; autopilot is a trusted local caller. Cycle purge phase (src/core/cycle.ts): Added op_checkpoints GC (+C folded scope item). 7-day TTL — any reasonable long-running op finishes inside that window. Non-fatal on pre-v67 brains (table missing). Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T8 + D7/D9/D10 + codex #17 + folded scope +C). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(core): brain-score-recommendations + op-checkpoint unit tests T10 of brain-health-100 wave — load-bearing decision-pinning tests. test/brain-score-recommendations.test.ts (22 cases): - Healthy brain → empty plan - Per-component remediation paths (sync, embed, backlinks, extract) - depends_on wiring (extract → sync; embed → sync when stale) - Severity ordering (critical > high > medium > low) - D6 #5 determinism: same input twice → byte-identical output - D9 idempotency keys: content-hash format, no time-slot - D9 source isolation: different --source → different key - D13 status field always 'remediable' in output - +A cost-estimate populated for embed - classifyChecks: remediable / blocked / human_only triage - maxReachableScore: all-remediable → 100; all-blocked → current test/op-checkpoint.test.ts (20 cases): - fingerprint stability + key-order invariance (canonical-JSON) - codex #11: extract links vs timeline get different fingerprints - codex #12: reindex markdown vs code get different fingerprints - codex #15: embed model+dim variation produces different fingerprints - reindex chunker_version bump invalidates checkpoint - DB round-trip (load → record → load) - Cross-fingerprint isolation (linksKey vs timelineKey) - clearOpCheckpoint idempotency on missing rows - resumeFilter purity (no I/O, deterministic) - purgeStaleCheckpoints TTL respect 42 new tests, all pass. PGLite engine + resetPgliteState pattern per CLAUDE.md test-isolation guide. Plan: ~/.claude/plans/system-instruction-you-are-working-fluttering-ocean.md (T10 + D6 #5 + D9 + D12 + D13 + codex #11/#12/#15). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): v0.36.0.0 — brain-health-100 wave + docs/llms refresh T12 of brain-health-100 wave. VERSION + package.json bumped 0.35.6.0 → 0.36.0.0. CHANGELOG entry leads ELI10 ("your agent can now drive your brain to 90/100 by itself, on a cron, without you watching") then drills into the precise mechanics per CLAUDE.md voice rules. llms.txt + llms-full.txt regenerated via bun run build:llms. Trio audit (CLAUDE.md mandatory pre-push check): VERSION: 0.36.0.0 package.json: 0.36.0.0 CHANGELOG: ## [0.36.0.0] - 2026-05-18 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update README/CLAUDE/AGENTS/maintain for v0.36.4.0 brain-health-100 wave - README.md: New-in-v0.36.4.0 callout — `gbrain doctor --remediate` headline, autopilot health-aware tick, eleven new background-job types, three PROTECTED. - CLAUDE.md: Key Files entries for `op-checkpoint.ts`, `brain-score-recommendations.ts`, doctor.ts / jobs.ts / protected-names.ts / autopilot.ts / cycle.ts / embed.ts / cli-options.ts extensions; new "Key commands added in v0.36.4.0" section. - AGENTS.md: Common-tasks entry pointing agents at the one-command remediation loop. - skills/maintain/SKILL.md: Autonomous Phase (gbrain doctor --remediate) at the top, manual per-dimension walk preserved as the fallback path. - llms-full.txt: regenerated to pick up the CLAUDE.md changes (project rule). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(changelog): respectful tone on spend caps for v0.36.4.0 Reframed the cost-budget callout. Pre-fix language said the spend cap prevents a synthesize loop from "burning $100 of Anthropic credits while you're at lunch" — casually treating $100 as the throwaway number is tone-deaf. $100 is a meaningful amount for many people. New language: "spend cap so a synthesize loop can't run up your Anthropic bill while you're at lunch. The cap is yours to set per run." And: "Pass --max-usd 5 (or whatever cap you're comfortable with)." And: "Pick the cap that fits your wallet." Also reframed three adjacent lines: - "healthy brains stop burning cycles" → "stop spending tokens on work that has nothing to do" - "agent can't submit them and burn your API budget" → "can't submit them on your behalf. Your provider bill stays in your hands" - Table cell "Cron with cost cap" / "--max-usd 5" → "Cron with spend cap" / "--max-usd N" llms-full.txt regenerated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
237 lines
8.8 KiB
TypeScript
237 lines
8.8 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
computeRecommendations,
|
|
classifyChecks,
|
|
maxReachableScore,
|
|
estimateAnthropicCost,
|
|
} from '../src/core/brain-score-recommendations.ts';
|
|
import type { BrainHealth } from '../src/core/types.ts';
|
|
|
|
/**
|
|
* D6 #5 + D13 + D14 pinning tests for brain-score-recommendations.
|
|
*
|
|
* Pure-function tests — no engine, no I/O. Every assertion below maps
|
|
* to an invariant the doctor-remediate loop assumes.
|
|
*/
|
|
|
|
function makeHealth(overrides: Partial<BrainHealth> = {}): BrainHealth {
|
|
return {
|
|
page_count: 100,
|
|
embed_coverage: 1.0,
|
|
stale_pages: 0,
|
|
orphan_pages: 0,
|
|
missing_embeddings: 0,
|
|
brain_score: 100,
|
|
dead_links: 0,
|
|
link_coverage: 1.0,
|
|
timeline_coverage: 1.0,
|
|
most_connected: [],
|
|
embed_coverage_score: 35,
|
|
link_density_score: 25,
|
|
timeline_coverage_score: 15,
|
|
no_orphans_score: 15,
|
|
no_dead_links_score: 10,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('computeRecommendations', () => {
|
|
test('healthy brain (score 100) produces empty plan', () => {
|
|
const health = makeHealth();
|
|
const recs = computeRecommendations(health, { repoPath: '/brain', hasEmbeddingApiKey: true });
|
|
expect(recs).toEqual([]);
|
|
});
|
|
|
|
test('missing embeddings produces embed.stale remediation', () => {
|
|
const health = makeHealth({ missing_embeddings: 1432, brain_score: 65 });
|
|
const recs = computeRecommendations(health, { repoPath: '/brain', hasEmbeddingApiKey: true });
|
|
const ids = recs.map((r) => r.id);
|
|
expect(ids).toContain('embed.stale');
|
|
const embedRec = recs.find((r) => r.id === 'embed.stale')!;
|
|
expect(embedRec.severity).toBe('critical');
|
|
expect(embedRec.job).toBe('embed');
|
|
expect(embedRec.params.stale).toBe(true);
|
|
});
|
|
|
|
test('missing embeddings + API key absent: NOT emitted (blocked surfaces separately)', () => {
|
|
const health = makeHealth({ missing_embeddings: 1432 });
|
|
const recs = computeRecommendations(health, { repoPath: '/brain', hasEmbeddingApiKey: false });
|
|
expect(recs.find((r) => r.id === 'embed.stale')).toBeUndefined();
|
|
});
|
|
|
|
test('stale pages + dead links produce sync + backlinks + extract', () => {
|
|
const health = makeHealth({
|
|
stale_pages: 25,
|
|
dead_links: 8,
|
|
brain_score: 70,
|
|
});
|
|
const recs = computeRecommendations(health, { repoPath: '/brain', hasEmbeddingApiKey: true });
|
|
const ids = recs.map((r) => r.id);
|
|
expect(ids).toContain('sync.repo');
|
|
expect(ids).toContain('backlinks.fix');
|
|
expect(ids).toContain('extract.all');
|
|
});
|
|
|
|
test('extract.all depends on sync.repo (D14: stable ids)', () => {
|
|
const health = makeHealth({ stale_pages: 10 });
|
|
const recs = computeRecommendations(health, { repoPath: '/brain', hasEmbeddingApiKey: true });
|
|
const extract = recs.find((r) => r.id === 'extract.all');
|
|
expect(extract?.depends_on).toContain('sync.repo');
|
|
});
|
|
|
|
test('embed.stale depends on sync.repo when sync also needed', () => {
|
|
const health = makeHealth({
|
|
stale_pages: 10,
|
|
missing_embeddings: 100,
|
|
});
|
|
const recs = computeRecommendations(health, { repoPath: '/brain', hasEmbeddingApiKey: true });
|
|
const embed = recs.find((r) => r.id === 'embed.stale');
|
|
expect(embed?.depends_on).toContain('sync.repo');
|
|
});
|
|
|
|
test('embed.stale has no sync dependency when nothing stale', () => {
|
|
const health = makeHealth({ missing_embeddings: 100 });
|
|
const recs = computeRecommendations(health, { repoPath: '/brain', hasEmbeddingApiKey: true });
|
|
const embed = recs.find((r) => r.id === 'embed.stale');
|
|
expect(embed?.depends_on).toEqual([]);
|
|
});
|
|
|
|
test('severity ordering: critical before high before medium', () => {
|
|
const health = makeHealth({
|
|
missing_embeddings: 100, // critical
|
|
stale_pages: 80, // high
|
|
});
|
|
const recs = computeRecommendations(health, { repoPath: '/brain', hasEmbeddingApiKey: true });
|
|
const critIdx = recs.findIndex((r) => r.severity === 'critical');
|
|
const highIdx = recs.findIndex((r) => r.severity === 'high');
|
|
expect(critIdx).toBeLessThan(highIdx);
|
|
});
|
|
|
|
// D6 #5 — THE critical regression test for the agent contract.
|
|
test('D6 #5: determinism — same input twice produces identical output', () => {
|
|
const health = makeHealth({
|
|
stale_pages: 10,
|
|
missing_embeddings: 50,
|
|
dead_links: 3,
|
|
});
|
|
const ctx = { repoPath: '/brain', hasEmbeddingApiKey: true, sourceId: 'default' };
|
|
const run1 = computeRecommendations(health, ctx);
|
|
const run2 = computeRecommendations(health, ctx);
|
|
expect(JSON.stringify(run1)).toBe(JSON.stringify(run2));
|
|
});
|
|
|
|
test('D9: idempotency keys are content-hash, no time-slot', () => {
|
|
const health = makeHealth({ missing_embeddings: 50 });
|
|
const recs = computeRecommendations(health, {
|
|
repoPath: '/brain',
|
|
hasEmbeddingApiKey: true,
|
|
sourceId: 'default',
|
|
});
|
|
const embed = recs.find((r) => r.id === 'embed.stale')!;
|
|
// No date in the key — content-hash only
|
|
expect(embed.idempotency_key).not.toMatch(/\d{4}-\d{2}-\d{2}/);
|
|
// Format: source:job:sha8
|
|
expect(embed.idempotency_key).toMatch(/^default:embed:[a-f0-9]{8}$/);
|
|
});
|
|
|
|
test('D9: different sources produce different idempotency keys', () => {
|
|
const health = makeHealth({ missing_embeddings: 50 });
|
|
const a = computeRecommendations(health, { repoPath: '/brain', hasEmbeddingApiKey: true, sourceId: 'A' });
|
|
const b = computeRecommendations(health, { repoPath: '/brain', hasEmbeddingApiKey: true, sourceId: 'B' });
|
|
expect(a[0]!.idempotency_key).not.toBe(b[0]!.idempotency_key);
|
|
});
|
|
|
|
test('status field is always remediable in the output list (D13)', () => {
|
|
const health = makeHealth({ missing_embeddings: 50 });
|
|
const recs = computeRecommendations(health, { repoPath: '/brain', hasEmbeddingApiKey: true });
|
|
for (const r of recs) expect(r.status).toBe('remediable');
|
|
});
|
|
|
|
test('+A cost estimate populated for embed', () => {
|
|
const health = makeHealth({ missing_embeddings: 1000 });
|
|
const recs = computeRecommendations(health, {
|
|
repoPath: '/brain',
|
|
hasEmbeddingApiKey: true,
|
|
embeddingModel: 'openai:text-embedding-3-large',
|
|
embeddingDimensions: 3072,
|
|
});
|
|
const embed = recs.find((r) => r.id === 'embed.stale')!;
|
|
expect(embed.est_usd_cost).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('classifyChecks (D13)', () => {
|
|
test('remediable: missing_embeddings with API key', () => {
|
|
const result = classifyChecks([{ name: 'missing_embeddings', status: 'fail' }], {
|
|
hasEmbeddingApiKey: true,
|
|
});
|
|
expect(result[0]).toEqual({ check: 'missing_embeddings', status: 'remediable' });
|
|
});
|
|
|
|
test('blocked: missing_embeddings without API key', () => {
|
|
const result = classifyChecks([{ name: 'missing_embeddings', status: 'fail' }], {
|
|
hasEmbeddingApiKey: false,
|
|
});
|
|
expect(result[0]?.status).toBe('blocked');
|
|
expect(result[0]?.reason).toContain('embedding');
|
|
});
|
|
|
|
test('blocked: dead_links without repoPath', () => {
|
|
const result = classifyChecks([{ name: 'dead_links', status: 'warn' }], {});
|
|
expect(result[0]?.status).toBe('blocked');
|
|
});
|
|
|
|
test('human_only: orphan_pages (archive is product judgment)', () => {
|
|
const result = classifyChecks([{ name: 'orphan_pages', status: 'warn' }], { repoPath: '/brain' });
|
|
expect(result[0]?.status).toBe('human_only');
|
|
});
|
|
|
|
test('human_only: unknown check defaults to operator judgment', () => {
|
|
const result = classifyChecks([{ name: 'mystery_check', status: 'warn' }], {});
|
|
expect(result[0]?.status).toBe('human_only');
|
|
});
|
|
});
|
|
|
|
describe('maxReachableScore (D13)', () => {
|
|
test('all remediable: full 100', () => {
|
|
const health = makeHealth({
|
|
embed_coverage_score: 0,
|
|
no_dead_links_score: 0,
|
|
no_orphans_score: 0,
|
|
});
|
|
const classes = [
|
|
{ check: 'missing_embeddings', status: 'remediable' as const },
|
|
{ check: 'dead_links', status: 'remediable' as const },
|
|
{ check: 'orphan_pages', status: 'remediable' as const },
|
|
];
|
|
expect(maxReachableScore(health, classes)).toBe(100);
|
|
});
|
|
|
|
test('all blocked: stays at current', () => {
|
|
const health = makeHealth({
|
|
brain_score: 50,
|
|
embed_coverage_score: 5,
|
|
no_dead_links_score: 5,
|
|
no_orphans_score: 10,
|
|
});
|
|
const classes = [
|
|
{ check: 'missing_embeddings', status: 'blocked' as const },
|
|
{ check: 'dead_links', status: 'blocked' as const },
|
|
{ check: 'orphan_pages', status: 'human_only' as const },
|
|
];
|
|
// 5 + 25 + 15 + 10 + 5 = 60
|
|
expect(maxReachableScore(health, classes)).toBe(60);
|
|
});
|
|
});
|
|
|
|
describe('estimateAnthropicCost', () => {
|
|
test('returns 0 for unknown model', () => {
|
|
expect(estimateAnthropicCost('unknown-model', 10)).toBe(0);
|
|
});
|
|
|
|
test('returns positive for known model', () => {
|
|
const cost = estimateAnthropicCost('claude-sonnet-4-6', 10);
|
|
expect(cost).toBeGreaterThan(0);
|
|
});
|
|
});
|