Files
gbrain/test/ai/zeroentropy-compat-fetch.test.ts
T
baf1a47798 v0.35.0.0 feat: ZeroEntropy zembed-1 + zerank-2 reranker (#1008)
* feat(ai): add ZeroEntropy recipe + reranker touchpoint type

Widens `TouchpointKind` with `'reranker'`, adds `RerankerTouchpoint`
interface, extends `Recipe.touchpoints` and `AIGatewayConfig` to carry
reranker model state. Registers `zeroentropyai` recipe (zembed-1
embeddings + zerank-{2,1,1-small} rerankers) in the recipe registry.

Recipe declares the 7 Matryoshka dims (2560/1280/640/320/160/80/40),
Voyage-style dense-payload hedge (chars_per_token=1, safety_factor=0.5),
and 5MB rerank payload cap. Pinned by test/ai/zeroentropy-recipe.test.ts
including F1 regression (implementation literal is 'openai-compatible')
and F2 regression (base_url_default ends with /v1, no doubling).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai/dims): thread input_type 4th-arg + ZE flexible-dim allowlist

`dimsProviderOptions` gains an optional `inputType?: 'query' | 'document'`
4th param so asymmetric providers (ZE zembed-1, Voyage v3+) can route
query-side vs document-side encoding. Per-model filtering inside the
openai-compatible branch keeps `input_type` from leaking to symmetric
providers (OpenAI text-3, DashScope, Zhipu) that would 400 on it.

Adds `ZEROENTROPY_VALID_DIMS` allowlist (2560/1280/640/320/160/80/40),
`supportsZeroEntropyDimension(modelId)`, and `isValidZeroEntropyDim(dims)`.
Throws `AIConfigError` with paste-ready fix hint when zembed-1 is
configured with an invalid dim (most common: defaulting to 1536 from
DEFAULT_EMBEDDING_DIMENSIONS).

The 4th-arg is optional; existing call sites (1 production + N tests
across Voyage/OpenAI/DashScope/Zhipu/MiniMax) compile unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai/gateway): zeroEntropyCompatFetch + embedQuery + gateway.rerank()

Two seams land together because they share the same recipe + auth path.

zeroEntropyCompatFetch handles ZE's non-OpenAI-compatible wire shape:
  - URL rewrite: SDK's `${base_url}/embeddings` -> `${base_url}/models/embed`
  - Body inject: `input_type` (default 'document'; 'query' when threaded
    via providerOptions) + explicit `encoding_format: 'float'`
  - Response rewrite: `{results: [{embedding}]}` -> `{data: [{embedding,
    index}]}` so the AI SDK's openai-compat schema validates
  - `usage.prompt_tokens` injected from `total_tokens` (Voyage hit the
    same SDK schema requirement at :655)
  - Layer 1 (Content-Length) + Layer 2 (per-embedding size) OOM caps
    via tagged `ZeroEntropyResponseTooLargeError` (kept separate from
    `VoyageResponseTooLargeError` because the Voyage cap tests do
    structural source-text greps pinning the Voyage name)
  - Wired in `instantiateEmbedding()` via the existing
    `recipe.id === 'voyage' ? voyageCompatFetch : ...` ternary pattern

embedQuery(text) routes `inputType: 'query'` through dimsProviderOptions
for the search hot path. Companion to embed(texts) which now takes an
optional 2nd-arg inputType (defaults to undefined -> 'document' for
asymmetric providers).

gateway.rerank() is the new native HTTP path (no AI-SDK reranking
abstraction). Resolves the configured reranker model via
`getRerankerModel()` (new accessor), parses + asserts the model is in
the recipe's touchpoint.reranker.models allowlist (CDX2-F11:
assertTouchpoint does not enforce allowlists for openai-compatible
recipes — rerank() does it directly). Posts to
`${recipe.base_url}/models/rerank` with bearer auth. Returns
`RerankResult[]` sorted by `relevanceScore`. Errors classify into
`RerankError.reason: 'auth' | 'rate_limit' | 'network' | 'timeout' |
'payload_too_large' | 'unknown'`. 5s default timeout. Pre-flight payload
guard rejects bodies over `recipe.max_payload_bytes` BEFORE any HTTP
call so applyReranker can fail-open without burning a round-trip.
`_rerankTransport` + `__setRerankTransportForTests` mirror the embed
test seam.

`AIGatewayConfig.reranker_model` + isAvailable('reranker') branch +
configureGateway / reconfigureGatewayWithEngine extensions thread the
reranker model through the same state path as embedding/expansion/chat.
`applyResolveAuth` + `defaultResolveAuth` widen the touchpoint param to
include `'reranker'`. `KnownTouchpointKey` + `getTouchpoint()` in
model-resolver widen to cover `'reranker'`.

Pinned by:
- test/ai/embedQuery.test.ts (8): returns single Float32Array, threads
  input_type='query' for ZE, drops field for OpenAI text-3,
  back-compat: legacy embed() callers without 4th arg keep their
  previous Voyage no-input_type shape
- test/ai/rerank.test.ts (21): URL (F2 regression — no /v1/v1/), body
  shape, bearer header, response parsing, error classification across
  6 HTTP shapes, payload pre-flight (no transport call), allowlist
  enforcement
- test/ai/zeroentropy-compat-fetch.test.ts (14): structural source
  assertions for the shim that mirror test/voyage-response-cap.test.ts —
  URL rewrite path, body injection, response rewrite, usage.prompt_tokens
  injection, OOM caps Layer 1 + Layer 2 + instanceof rethrow,
  instantiateEmbedding wiring branch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(search): applyReranker + rerank-failure audit + hybrid wire-in

src/core/search/rerank.ts — the call-site abstraction. Slices the top
`opts.topNIn` deduped candidates, sends to gateway.rerank(), reorders by
relevanceScore desc, appends the un-reranked tail in its original RRF
order (recall protection). Fail-open on every RerankError.reason: logs
via `logRerankFailure` and returns the input array unchanged. Stamps
`rerank_score` onto reordered items. `topNOut: null` is the explicit
"don't truncate" signal — distinct from `undefined` (fall through to
mode bundle); pin in test (CDX2-F16).

src/core/rerank-audit.ts — failure-only JSONL audit at
`~/.gbrain/audit/rerank-failures-YYYY-Www.jsonl` (ISO-week rotation;
mirrors `src/core/audit-slug-fallback.ts`). Exports `logRerankFailure`
+ `readRecentRerankFailures(days)`. **No `logRerankSuccess`** — CDX2-F22
deliberately drops success-event logging: writing once per tokenmax
search is hot-path I/O churn AND success events leak query
volume + timing into a local audit. The doctor check reads
`search.reranker.enabled` first so "no events in window" gets
interpreted correctly (disabled -> healthy by definition; enabled ->
healthy because nothing failed). Query text is SHA-256-prefix-hashed
(8 hex chars) for privacy. Honors `GBRAIN_AUDIT_DIR`.

src/core/search/hybrid.ts — slots `applyReranker` between
`dedupResults()` and `enforceTokenBudget()` in the main RRF path.
Resolution: per-call `opts.reranker` overrides; otherwise pulled from
the resolved mode bundle (tokenmax -> enabled, others -> disabled in
commit 5). Cache rows store final reranked results; the bumped
knobsHash (commit 5) ensures rows can't leak across reranker configs.

src/core/types.ts — adds `SearchOpts.reranker` as a structural type so
callers can pass per-call overrides; runtime type lives in
src/core/search/rerank.ts (avoids circular import).

Tests:
- test/search/rerank.test.ts (14): reorder, tail preserve, fail-open on
  every error class, topNOut null vs number, score stamping, empty +
  enabled=false pass-through
- test/rerank-audit.test.ts (10): JSONL round-trip, error_summary
  truncated to 200, corrupt rows skipped, missing dir -> [], ISO-week
  rotation walks current + previous week, no logRerankSuccess export
  (CDX2-F22 contract)
- test/search/hybrid-reranker-integration.test.ts (6): reranker fires
  when enabled, doesn't when disabled, reorders correctly, preserves
  tail, stamps rerank_score, fail-opens on rerankerFn throw — uses
  PGLite + stubbed embed transport, no API keys

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(search/mode): reranker mode-bundle fields + KNOBS_HASH_VERSION v=2

Extends `ModeBundle` with five reranker fields: `reranker_enabled`,
`reranker_model`, `reranker_top_n_in`, `reranker_top_n_out`,
`reranker_timeout_ms`. Per-mode defaults:

  - conservative -> enabled=false (cost-sensitive)
  - balanced     -> enabled=false (opt-in via search.reranker.enabled)
  - tokenmax     -> enabled=true  (the high-cost-tolerant tier; ~$0.0003/query)

Defaults model to `zeroentropyai:zerank-2`, topNIn=30, topNOut=null
(no truncate by default; preserves tokenmax's searchLimit=50 end-to-end
per CDX2-F16), timeout_ms=5000.

`SearchKeyOverrides` + `SearchPerCallOpts` + `resolveSearchMode.pick`
all extend to thread the new fields through the resolution chain
(per-call -> per-key config -> mode bundle -> default).

`loadOverridesFromConfig` adds parsers for the five new
`search.reranker.*` config keys. `top_n_out` parsing distinguishes
three input shapes (CDX2-F15):
  key absent           -> undefined (fall through to mode bundle)
  'null'|'none'|empty  -> explicit null (no truncate)
  positive integer     -> that number

`SEARCH_MODE_CONFIG_KEYS` extends so `gbrain search modes --reset`
clears the reranker overrides too.

**KNOBS_HASH_VERSION bumps 1 -> 2** (CDX1-F14). Five new entries
appended to `parts[]` (append-only convention CDX2-F13; reordering
existing fields would silently rebuild every existing cache row).
Includes `reranker_timeout_ms` so a 5s -> 100ms change invalidates
stale rows (CDX2-F14: more fail-opens = different search behavior).

Mid-rolling-deploy note (CDX2-F12): v=1 and v=2 processes produce
distinct cacheRowIds for the same (source_id, query_text). Expect a
temporary hit-rate dip + cache-row doubling for hot queries. Clears
naturally within `cache.ttl_seconds` (default 3600s).

src/commands/search.ts extends `KNOB_DESCRIPTIONS` with five new
entries so `gbrain search modes` renders them. test/search-mode.test.ts
extends the three bundle fixtures and bumps the KNOBS_HASH_VERSION
expectation to 2.

Pinned by test/search/knobs-hash-reranker.test.ts (13): each of the 5
reranker fields independently flips the hash, top_n_out=null renders
stable, append-only convention enforced via source-position assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(doctor): probeRerankerConfig + reranker_health check

`gbrain models doctor` gains two new probes:

- `probeRerankerConfig` (zero-network) validates that the configured
  reranker model resolves through the recipe registry, that the recipe
  declares a `reranker` touchpoint, and that the model is in
  `touchpoint.models[]`. Direct allowlist check here — assertTouchpoint
  does not enforce allowlists for openai-compatible recipes (CDX2-F11).
  Surfaces paste-ready `gbrain config set search.reranker.model
  <zerank-2|zerank-1|zerank-1-small>` fix hint.

- `probeRerankerReachability` (1-token-equivalent) sends a minimal
  `{query: "probe", documents: ["probe"]}` rerank to verify auth + URL.
  Failures classify via `classifyError` into auth/rate_limit/network/
  unknown. Skipped silently when reranker is unconfigured.

Also extends `probeEmbeddingConfig` with a `providerId === 'zeroentropyai'`
branch that catches the silent-1536-default bug class for zembed-1
configurations (same posture as the existing Voyage branch).

`ProbeResult.touchpoint` widens to include `'reranker_config'`.

`gbrain doctor` adds `checkRerankerHealth` to both the abbreviated
(doctorReportRemote) and full (runDoctor) check sets. Logic:

  1) Read `search.reranker.enabled` first. Disabled + no failures =>
     'reranker disabled'. Enabled + no failures => healthy.
  2) Walk last 7 days of ~/.gbrain/audit/rerank-failures-*.jsonl.
  3) ANY auth failure warns (config-time problem the probe should have
     caught — surface it).
  4) ANY payload_too_large failure warns (workload mismatch).
  5) Transient (network/timeout/rate_limit) warns at >=5 in window.
     Below that they're noise; reranker fails open anyway.

CDX2-F21 blind-spot fix: reading enabled state first means "no events"
gets interpreted correctly — never confuses "never-used" with "success
logging broken" (the latter is impossible because there is no success
logging by design, CDX2-F22).

Engine-agnostic; file-based + one config-key read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): ZeroEntropy live API round-trip + wire into Tier 2 CI

test/e2e/zeroentropy-live.test.ts exercises the full stack against the
real api.zeroentropy.dev: embed (default 2560-dim + flexible 1280),
embedQuery (asymmetric query side), batch embed (3 distinct vectors),
rerank (3 docs sorted by relevance score, photosynthesis-relevant docs
beat the irrelevant cat doc), rerank with topN truncation.

Gated on `ZEROENTROPY_API_KEY`: every test prints `[skip]` and returns
early without assertions when the env var is unset, so fork PRs and
contributor machines without a ZE account stay green.

CI wire-up: `.github/workflows/e2e.yml` Tier 2 step adds
`test/e2e/zeroentropy-live.test.ts` to its `bun test` invocation and
exposes `ZEROENTROPY_API_KEY: ${{ secrets.ZEROENTROPY_API_KEY }}` to
the runner. The secret is set on garrytan/gbrain at the repo scope
(separately from this commit — set via `gh secret set` so the value
never lands in source).

Tier 1 stays mechanical (no API keys); Tier 2 is the natural home for
provider-live tests because it's already the API-keyed lane.

Cost: each full run fires ~6 small HTTP calls totaling well under a
cent at the published $0.025/1M-token rate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.33.3.0 feat: ZeroEntropy zembed-1 + zerank-2 reranker

Release notes for the ZeroEntropy support wave: zembed-1 embeddings
(flexible-dim 2560/1280/640/320/160/80/40, asymmetric input_type) and
zerank-2 cross-encoder reranking land as a new openai-compatible recipe
alongside OpenAI/Voyage. Reranker defaults ON for tokenmax mode, OFF
for conservative/balanced (~$0.0003/query at tokenmax topNIn=30; rounding
error vs the tier's $700/mo Opus pairing per the CLAUDE.md cost matrix).

Search now ends with `RRF -> dedup -> reranker -> token-budget` when
reranker is enabled; fails open to RRF order on any error class
(audit-logged at ~/.gbrain/audit/rerank-failures-*.jsonl).

`KNOBS_HASH_VERSION` bumps 1 -> 2 to fold reranker config into the
query_cache row key. Rolling-deploy operators should expect a temporary
cache hit-rate dip + cache-row doubling for hot queries (clears
naturally within `cache.ttl_seconds`, default 3600s).

Files in this commit are pure docs / version bump:
- VERSION + package.json bump to 0.33.3.0
- CHANGELOG.md release-summary entry with "How to take advantage" block
- CLAUDE.md Key Files annotations for the new recipe + rerank.ts +
  rerank-audit.ts + gateway extensions
- docs/ai-providers/zeroentropy.md one-pager (setup, knob reference,
  failure observability, troubleshooting table)
- skills/migrations/v0.33.3.md (purely informational: no required user
  action; reranker is opt-in everywhere, ZE embedding is opt-in)
- llms-full.txt regenerated to match CLAUDE.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:48:36 -07:00

161 lines
7.8 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.
/**
* v0.35.0.0 — structural source-shape assertions for zeroEntropyCompatFetch.
*
* Mirrors the test/voyage-response-cap.test.ts pattern: the shim is a
* closed-over helper inside the embedding instantiation path and isn't
* exported. Behavioral coverage of ZE flows happens in the gateway/embed
* tests (with __setEmbedTransportForTests stubbing); these structural
* pins guard the SHAPE of the shim so a silent revert fails loudly.
*
* Pins:
* - URL path rewrite `/embeddings` → `/models/embed`.
* - Body inject: input_type default to 'document'; encoding_format forced
* to 'float' (don't trust SDK default per CDX2-F2).
* - Response rewrite: `{results: [{embedding}]}` → `{data: [{embedding,
* index}]}`.
* - usage.prompt_tokens injection (CDX2-F1 — AI SDK schema requires it
* when usage is present; Voyage's shim already had to do this).
* - OOM caps: MAX_ZEROENTROPY_RESPONSE_BYTES=256MB; Layer 1
* Content-Length, Layer 2 per-embedding size.
* - instantiateEmbedding branch: `recipe.id === 'zeroentropyai'` →
* `zeroEntropyCompatFetch`.
*/
import { describe, test, expect } from 'bun:test';
const GATEWAY_PATH = new URL('../../src/core/ai/gateway.ts', import.meta.url);
describe('zeroEntropyCompatFetch — shim structural shape', () => {
test('declared at module scope alongside voyageCompatFetch', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
expect(src).toMatch(/const zeroEntropyCompatFetch\s*=\s*\(async \(input: RequestInfo \| URL/);
});
test('cast through unknown to typeof fetch (Bun preconnect compat)', async () => {
// The Voyage shim has the same trick — without `as unknown as typeof
// fetch` the function-arrow type loses Bun's `preconnect` method
// signature. Pinning here prevents a future refactor from removing
// the cast and re-introducing the tsc TS2741 failure documented in
// gateway.ts:556 comments.
const src = await Bun.file(GATEWAY_PATH).text();
expect(src).toMatch(/\}\)\s*as unknown as typeof fetch;[\s\S]{0,200}async function resolveEmbeddingProvider/);
});
test('URL rewrite: /embeddings → /models/embed (CDX1-F2)', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
// The rewrite logic must:
// - normalize input via toString() so URL/string/Request all work
// - replace exact suffix `/embeddings` with `/models/embed`
expect(src).toMatch(/u\.pathname\.endsWith\(['"]\/embeddings['"]\)/);
expect(src).toMatch(/\/models\/embed/);
// Negative: must NOT have a `/v1/v1/` form (that would be the
// pre-fix double-prefix bug).
expect(src).not.toContain('/v1/v1/');
});
test('body injects input_type default "document"', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
// The wrapper defaults input_type to 'document' when caller didn't
// thread one (matches the document-side correctness for sync /
// import / embed CLI paths).
expect(src).toMatch(/parsed\.input_type\s*===\s*undefined/);
expect(src).toMatch(/parsed\.input_type\s*=\s*['"]document['"]/);
});
test('body forces encoding_format=float (CDX2-F2)', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
// Don't trust SDK default; refuse base64 to keep response rewriter
// simple (no base64 decode path needed).
expect(src).toMatch(/parsed\.encoding_format\s*!==\s*['"]float['"]/);
expect(src).toMatch(/parsed\.encoding_format\s*=\s*['"]float['"]/);
});
test('response shape rewrite: results → data with index stamped', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
// The wrapper converts ZE's {results: [{embedding}]} to
// {data: [{embedding, index}]} for the SDK's openai-compat parser.
expect(src).toMatch(/json\.data\s*=\s*json\.results\.map\(/);
expect(src).toMatch(/embedding:\s*r\?\.embedding/);
expect(src).toMatch(/index:\s*i/);
expect(src).toMatch(/delete json\.results/);
});
test('usage.prompt_tokens injected from total_tokens (CDX2-F1)', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
// AI SDK schema requires prompt_tokens when usage is present. Voyage's
// shim had the same fix at gateway.ts:655.
expect(src).toMatch(/json\.usage\.prompt_tokens\s*===\s*undefined/);
expect(src).toMatch(/json\.usage\.prompt_tokens\s*=\s*\n?\s*typeof json\.usage\.total_tokens\s*===\s*['"]number['"]/);
});
});
describe('zeroEntropyCompatFetch — OOM caps', () => {
test('MAX_ZEROENTROPY_RESPONSE_BYTES declared at 256 MB (matches Voyage sizing)', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
expect(src).toContain('MAX_ZEROENTROPY_RESPONSE_BYTES');
expect(src).toMatch(/MAX_ZEROENTROPY_RESPONSE_BYTES\s*=\s*256\s*\*\s*1024\s*\*\s*1024/);
});
test('Layer 1: Content-Length pre-check before resp.clone().json()', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
// Find the zeroEntropyCompatFetch block bounds, then assert ordering
// within it (mirroring the voyage cap test pattern).
const zeFetchStart = src.indexOf('const zeroEntropyCompatFetch');
expect(zeFetchStart).toBeGreaterThan(0);
const block = src.slice(zeFetchStart, zeFetchStart + 8000);
const preCheckIdx = block.indexOf("resp.headers.get('content-length')");
const jsonParseIdx = block.indexOf('await resp.clone().json()');
expect(preCheckIdx).toBeGreaterThan(0);
expect(jsonParseIdx).toBeGreaterThan(0);
// The pre-check MUST appear before the JSON parse — Voyage's lesson
// (codex OV8 finding): without the ordering, the OOM defense is
// theatrical.
expect(preCheckIdx).toBeLessThan(jsonParseIdx);
});
test('Layer 1 throws ZeroEntropyResponseTooLargeError (not silent return)', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
expect(src).toMatch(/throw new ZeroEntropyResponseTooLargeError\([\s\S]{0,200}Content-Length/);
});
test('Layer 2: per-embedding size cap inside results iteration', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
// ZE returns float[] arrays (not base64 like Voyage). The cap counts
// elements × 4 bytes (Float32 width).
expect(src).toMatch(/item\.embedding\.length\s*\*\s*4/);
expect(src).toMatch(/throw new ZeroEntropyResponseTooLargeError\([\s\S]{0,200}embedding exceeds/);
});
test('inbound try/catch rethrows ZeroEntropyResponseTooLargeError', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
// Same rethrow pattern as Voyage's catch-block: instanceof check
// ensures OOM-cap throws aren't silently swallowed by the parse-error
// fallback.
expect(src).toContain('ZeroEntropyResponseTooLargeError');
expect(src).toMatch(/if\s*\(\s*err\s+instanceof\s+ZeroEntropyResponseTooLargeError\s*\)\s*throw\s+err/);
});
});
describe('instantiateEmbedding wiring', () => {
test('recipe.id === "zeroentropyai" branch installs zeroEntropyCompatFetch', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
// Pinned: the ternary at gateway.ts that picks the right fetch wrapper
// for openai-compat recipes. Without this branch, the shim is dead code.
expect(src).toMatch(/recipe\.id\s*===\s*['"]zeroentropyai['"]\s*\?[\s]*zeroEntropyCompatFetch/);
});
test('branch lives in the openai-compatible case of instantiateEmbedding', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
const fnIdx = src.indexOf('function instantiateEmbedding(');
const ocIdx = src.indexOf("case 'openai-compatible':", fnIdx);
const branchIdx = src.indexOf('zeroEntropyCompatFetch', ocIdx);
expect(fnIdx).toBeGreaterThan(0);
expect(ocIdx).toBeGreaterThan(0);
// Branch sits within ~2KB of the case opener (in the fetchWrapper
// ternary) — a sanity bound on where it lives.
expect(branchIdx).toBeGreaterThan(ocIdx);
expect(branchIdx - ocIdx).toBeLessThan(2000);
});
});