Files
gbrain/test/entity-card-perf.slow.test.ts
T
Garry TanandClaude Opus 4.8 a996e42856 v0.45.2.0 fix(bootstrap): create-repo-first repo adoption + hardening (#4024)
* 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>
2026-08-11 23:01:22 -07:00

188 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});