mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
* fix(bootstrap): harden create-repo-first repo adoption `gbrain bootstrap repo` adopts an empty, private, personally-owned GitHub repo the human created (create-repo-first), instead of only ever creating one. This hardens the existing adoption branch: - Empty-only adoption + pending_repo_url proof: a non-empty origin is refused (ORIGIN_NOT_EMPTY) unless it matches this workspace's pending marker (our own interrupted push). Never adopts a user's existing project from a git-ancestry guess, and never silently no-ops without pushing. - Repo-local git identity is set on the adopt path too (fresh-machine commits). - repo_url is recorded only AFTER a successful push (pending marker before); a failed push no longer looks "done" to `bootstrap status`. - Pre-push secret scan also covers an already-committed tree; ls-files failure fails closed. - assertOriginMatches binds BOTH the fetch URL and a configured push URL to the verified-private repo, so a foreign pushurl can't leak the workspace. - disposition: 'created' | 'adopted' | 'reused' replaces the overloaded flag. - Hook push-gate: the no-daemon session-end / recovery push is deferred until the repo phase records repo_url AND the current origin still matches it, so nothing is published to an unverified or redirected remote. Adds ORIGIN_NOT_EMPTY / REMOTE_CHECK_FAILED error codes. * docs(bootstrap): lead with the repo, document create-repo-first README (Claude Code + Codex) now opens with "the folder you open becomes your agent's private repo" and adds a "prefer to make the repo yourself?" callout for the create-repo-first path (empty, personal-account repo). Updates the bootstrap guide, the Claude Code MCP note, and the KEY_FILES / AGENT_BOOTSTRAP_PLAN invariants to describe adoption instead of "foreign origins refused". * v0.45.1.0 fix(bootstrap): create-repo-first repo adoption + hardening Bumps VERSION/package.json to 0.45.1.0, adds the CHANGELOG entry, refreshes the runbook + template-repo version stamps, and regenerates the llms bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(todos): file P2 follow-up — index-blob secret scan for bootstrap pushes * ci(gitleaks): run the free CLI instead of the license-gated v2 action gitleaks-action@v2 now enforces a paid GITLEAKS_LICENSE and fails the job ("missing gitleaks license") for accounts it can't validate over the API — blocking every PR's merge gate. Replace it with the open-source gitleaks CLI (pinned 8.30.1, checksum-verified against the release's own checksums file), scanning the PR/push commit range with the committed .gitleaks.toml allowlist. Same secret-scan coverage, no license wall. * v0.45.2.0 chore(release): re-bump 0.45.1.0 -> 0.45.2.0 Re-target the release version at the user's request. Updates VERSION, package.json, the CHANGELOG header + self-repair block, the runbook + template-repo version stamps, the TODOS follow-up reference, and the llms bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(perf): raise entity-card ratio ceiling 50x -> 100x (CI flake) The RATIO GUARD asserted entity p99 <= 50x max(getPage p50, 1ms). On a fast runner getPage p50 floors to 1ms and a normal entity p99 (~50ms) reads as ~52x, tripping the gate even though absolute p99 (52ms) is well under the 100ms budget — a p99 tail divided by a sub-ms median. At the 1ms floor, 50x also made the ratio STRICTER than the test's own 100ms absolute budget. Raise the ceiling to 100x: still far below the >=200x O(N)-regression signal the guard exists to catch, and consistent with (never stricter than) the absolute budget. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
188 lines
8.5 KiB
TypeScript
188 lines
8.5 KiB
TypeScript
/**
|
||
* MEMORY_VERBS v1 — entity() latency gate (Cathedral 1, frozen contract:
|
||
* p99 < 100ms on a large corpus, zero LLM).
|
||
*
|
||
* Corpus: 20K pages / 100K links / 30K aliases / 40K facts seeded via
|
||
* generate_series (pattern: entity-resolve-perf.slow.test.ts). 20 warmup +
|
||
* 200 measured buildEntityCard calls over a mixed name set exercising all
|
||
* three resolution arms (alias hit / exact title / slug-suffix) + misses.
|
||
*
|
||
* Two gates:
|
||
* 1. HARD ABSOLUTE — p99 < 100ms × GBRAIN_PERF_BUDGET_MULTIPLIER (default 1;
|
||
* loosen in CI only with evidence of runner noise). The protocol DOC
|
||
* promises this number; the bound is op-layer latency (transport
|
||
* excluded, as documented).
|
||
* 2. RATIO GUARD (machine-independent) — entity p99 ≤ 100× max(getPage p50,
|
||
* 1ms) on the same corpus. Calibration: the card is ~7 indexed reads +
|
||
* a keyword search on the miss path. It measures ~21× a getPage p50 of
|
||
* ~2.5ms, but on a fast runner getPage p50 floors to 1ms and normal
|
||
* entity p99 (~50ms) reads as ~50×. An O(N) scan regression lands at
|
||
* 200ms+ (≥200×), far past the ceiling even on a slow runner. The ceiling
|
||
* is 100× (not 50×) so the guard is never STRICTER than the 100ms absolute
|
||
* budget when getPage floors to 1ms — the earlier 50× tripped on fast
|
||
* runners (a p99 tail ÷ a sub-ms median) while p99 stayed well under budget.
|
||
*
|
||
* The 200K-page validation is a documented MANUAL recipe in
|
||
* docs/protocol/MEMORY_VERBS_v1.md — not CI-gated (seed time would dominate).
|
||
*
|
||
* .slow.test.ts suffix keeps it out of the fast loop (`bun run test:slow`).
|
||
*/
|
||
|
||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||
import { buildEntityCard } from '../src/core/verbs/entity-card.ts';
|
||
|
||
let engine: PGLiteEngine;
|
||
|
||
const PAGES = 20_000;
|
||
const LINKS = 100_000;
|
||
const ALIASES = 30_000;
|
||
const FACTS = 40_000;
|
||
const WARMUP = 20;
|
||
const MEASURED = 200;
|
||
const TARGET_ENTITIES = 50; // pages the measured calls rotate over
|
||
|
||
const P99_BUDGET_MS = 100 * (Number(process.env.GBRAIN_PERF_BUDGET_MULTIPLIER) || 1);
|
||
// entity p99 ≤ 100× max(getPage p50, 1ms) — see the calibration note above.
|
||
// (100×, not 50×: at the 1ms getPage floor, 50× would cap p99 at 50ms — stricter
|
||
// than the 100ms absolute budget — and tripped on fast runners where a p99 tail
|
||
// is divided by a sub-ms getPage median. 100× stays far below the ≥200× O(N)
|
||
// regression signal.)
|
||
const RATIO_CEILING = 100;
|
||
|
||
function percentile(sorted: number[], p: number): number {
|
||
const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
|
||
return sorted[Math.max(0, idx)];
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
engine = new PGLiteEngine();
|
||
await engine.connect({});
|
||
await engine.initSchema();
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const db = (engine as any).db;
|
||
|
||
// Target entities (real putPage so frontmatter/title behave like prod pages).
|
||
for (let i = 0; i < TARGET_ENTITIES; i++) {
|
||
const slug = `people/target-person-${i}`;
|
||
await engine.putPage(slug, {
|
||
type: 'person',
|
||
title: `Target Person ${i}`,
|
||
compiled_truth: `# Target Person ${i}\n\nRuns area ${i} at a-company. Synthetic perf-corpus entity.`,
|
||
frontmatter: { type: 'person', title: `Target Person ${i}`, slug, summary: `Synthetic target ${i} for the entity-card latency gate.` },
|
||
}, { sourceId: 'default' });
|
||
}
|
||
|
||
// Filler pages in one generate_series insert.
|
||
await db.query(
|
||
`INSERT INTO pages (slug, type, title, compiled_truth, frontmatter, source_id, created_at, updated_at)
|
||
SELECT 'filler/page-' || gs::text, 'note', 'Filler ' || gs::text, '# Filler', '{}', 'default', NOW(), NOW()
|
||
FROM generate_series(1, ${PAGES}) gs`,
|
||
);
|
||
|
||
// Links: filler→filler hub noise plus a fan-in/out around every target
|
||
// (the card reads getLinks/getBacklinks — targets must have real edges).
|
||
await db.query(
|
||
`INSERT INTO links (from_page_id, to_page_id, link_type, link_source)
|
||
SELECT p1.id, p2.id, 'mentions', 'mentions'
|
||
FROM (SELECT id, row_number() OVER (ORDER BY id) rn FROM pages WHERE slug LIKE 'filler/%') p1
|
||
JOIN (SELECT id, row_number() OVER (ORDER BY id) rn FROM pages WHERE slug LIKE 'filler/%') p2
|
||
ON p2.rn = ((p1.rn * 7919) % ${PAGES}) + 1 AND p1.id <> p2.id
|
||
CROSS JOIN generate_series(1, ${Math.ceil(LINKS / PAGES)}) g
|
||
ON CONFLICT DO NOTHING`,
|
||
);
|
||
await db.query(
|
||
`INSERT INTO links (from_page_id, to_page_id, link_type, link_source)
|
||
SELECT t.id, f.id, 'works_at', 'markdown'
|
||
FROM (SELECT id, row_number() OVER (ORDER BY id) rn FROM pages WHERE slug LIKE 'people/target-%') t
|
||
JOIN (SELECT id, row_number() OVER (ORDER BY id) rn FROM pages WHERE slug LIKE 'filler/%' LIMIT 2000) f
|
||
ON (f.rn % ${TARGET_ENTITIES}) + 1 = t.rn
|
||
ON CONFLICT DO NOTHING`,
|
||
);
|
||
|
||
// Aliases: bulk noise + 2 aliases per target.
|
||
await db.query(
|
||
`INSERT INTO page_aliases (source_id, alias_norm, slug)
|
||
SELECT 'default', 'alias noise ' || gs::text, 'filler/page-' || ((gs % ${PAGES}) + 1)::text
|
||
FROM generate_series(1, ${ALIASES}) gs
|
||
ON CONFLICT DO NOTHING`,
|
||
);
|
||
for (let i = 0; i < TARGET_ENTITIES; i++) {
|
||
await db.query(
|
||
`INSERT INTO page_aliases (source_id, alias_norm, slug) VALUES
|
||
('default', $1, $2), ('default', $3, $2)
|
||
ON CONFLICT DO NOTHING`,
|
||
[`tp${i}`, `people/target-person-${i}`, `target alias ${i}`],
|
||
);
|
||
}
|
||
|
||
// Facts: bulk noise across fillers + 20 active facts per target entity.
|
||
await db.query(
|
||
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability, valid_from, source, confidence, created_at)
|
||
SELECT 'default', 'filler/page-' || ((gs % ${PAGES}) + 1)::text,
|
||
'noise fact ' || gs::text, 'fact', 'world', 'medium', NOW(), 'perf-seed', 1.0, NOW()
|
||
FROM generate_series(1, ${FACTS - TARGET_ENTITIES * 20}) gs`,
|
||
);
|
||
await db.query(
|
||
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability, valid_from, source, confidence, created_at)
|
||
SELECT 'default', 'people/target-person-' || t::text,
|
||
'target fact ' || g::text || ' about person ' || t::text,
|
||
CASE WHEN g % 5 = 0 THEN 'commitment' ELSE 'fact' END,
|
||
'world', 'medium', NOW(), 'perf-seed', 1.0, NOW()
|
||
FROM generate_series(0, ${TARGET_ENTITIES - 1}) t, generate_series(1, 20) g`,
|
||
);
|
||
}, 300_000);
|
||
|
||
afterAll(async () => {
|
||
await engine.disconnect();
|
||
});
|
||
|
||
describe('entity card p99 latency gate', () => {
|
||
it(`p99 < ${P99_BUDGET_MS}ms on ${PAGES} pages AND ≤ ${RATIO_CEILING}× getPage p50`, async () => {
|
||
// Mixed name set: alias hits, exact titles, namespaced slugs, suffixes, misses.
|
||
const names: string[] = [];
|
||
for (let i = 0; i < TARGET_ENTITIES; i++) {
|
||
names.push(`tp${i}`); // alias arm
|
||
names.push(`Target Person ${i}`); // exact-title arm
|
||
names.push(`people/target-person-${i}`); // exact-slug arm
|
||
names.push(`target-person-${i}`); // slug-suffix arm
|
||
names.push(`zzz-absent-${i}`); // miss (suggestions path)
|
||
}
|
||
|
||
for (let i = 0; i < WARMUP; i++) {
|
||
await buildEntityCard(engine, 'default', names[i % names.length], { remote: true });
|
||
}
|
||
|
||
const samples: number[] = [];
|
||
for (let i = 0; i < MEASURED; i++) {
|
||
const name = names[(i * 13) % names.length];
|
||
const t0 = performance.now();
|
||
await buildEntityCard(engine, 'default', name, { remote: true });
|
||
samples.push(performance.now() - t0);
|
||
}
|
||
samples.sort((a, b) => a - b);
|
||
const p50 = percentile(samples, 50);
|
||
const p99 = percentile(samples, 99);
|
||
|
||
// Ratio baseline: getPage p50 on the same corpus.
|
||
const pageSamples: number[] = [];
|
||
for (let i = 0; i < 50; i++) {
|
||
const t0 = performance.now();
|
||
await engine.getPage(`people/target-person-${i % TARGET_ENTITIES}`, { sourceId: 'default' });
|
||
pageSamples.push(performance.now() - t0);
|
||
}
|
||
pageSamples.sort((a, b) => a - b);
|
||
const pageP50 = Math.max(percentile(pageSamples, 50), 1.0); // 1ms floor vs sub-ms division noise
|
||
|
||
// eslint-disable-next-line no-console
|
||
console.log(
|
||
`[entity-card-perf] corpus=${PAGES}p+${LINKS}l+${ALIASES}a+${FACTS}f ` +
|
||
`entity p50=${p50.toFixed(2)}ms p99=${p99.toFixed(2)}ms | getPage p50=${pageP50.toFixed(2)}ms ` +
|
||
`| ratio=${(p99 / pageP50).toFixed(1)}x (ceiling ${RATIO_CEILING}x) | budget=${P99_BUDGET_MS}ms`,
|
||
);
|
||
|
||
expect(p99).toBeLessThan(P99_BUDGET_MS);
|
||
expect(p99 / pageP50).toBeLessThanOrEqual(RATIO_CEILING);
|
||
}, 300_000);
|
||
});
|