@dzhechkov/harness-core 0.8.35 → 0.8.36
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/.dz-manifest.json +42 -42
- package/README.md +101 -3
- package/dist/apply-leg.d.ts +38 -0
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +263 -16
- package/dist/apply-leg.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +67 -5
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +13 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +13 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts +1 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +18 -2
- package/dist/operations.js.map +1 -1
- package/dist/publish.d.ts +59 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +205 -32
- package/dist/publish.js.map +1 -1
- package/dist/release-line.d.ts +16 -0
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +31 -0
- package/dist/release-line.js.map +1 -1
- package/dist/vector-tier.d.ts +34 -3
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +105 -14
- package/dist/vector-tier.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +41 -41
- package/src/apply-leg.ts +282 -14
- package/src/codex-hooks-assets.ts +67 -5
- package/src/codex-hooks.ts +13 -1
- package/src/index.ts +6 -1
- package/src/operations.ts +18 -3
- package/src/publish.ts +247 -30
- package/src/release-line.ts +32 -0
- package/src/vector-tier.ts +127 -14
package/src/publish.ts
CHANGED
|
@@ -21,7 +21,7 @@ type ExecSyncOptionsWithStringEncoding = NonNullable<Parameters<typeof execSync>
|
|
|
21
21
|
import { createHash } from 'node:crypto';
|
|
22
22
|
|
|
23
23
|
import { claimCheck } from './claim-check.js';
|
|
24
|
-
import { rewriteReleaseLine } from './release-line.js';
|
|
24
|
+
import { rewriteReleaseLine, isReleaseLineToken } from './release-line.js';
|
|
25
25
|
import { packedTarballName } from './packed-install-smoke.js';
|
|
26
26
|
|
|
27
27
|
export type ProbeOutcome = {
|
|
@@ -54,6 +54,21 @@ export interface PublishResult {
|
|
|
54
54
|
* unmodified `publishPackages` call is byte-compatible with pre-gate behavior.
|
|
55
55
|
*/
|
|
56
56
|
readonly claimCheck?: { readonly findings: number; readonly high: number } | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* FR-3 (feature publish-readme-stamp-scope): a preview of what `planReadmeVersionSync` would do
|
|
59
|
+
* (dry-run) or already did (live) to this package's own README.md — never silent about the
|
|
60
|
+
* lock-step sync. `lines` are the 1-based line numbers actually rewritten; `skippedHistorical` is
|
|
61
|
+
* a TOKEN count (changelog-region entries + tokens outside every recognised ALLOWLIST shape), not
|
|
62
|
+
* a line count; `historyLines` names WHERE those kept-as-history tokens sit (fix-round 1, Codex
|
|
63
|
+
* HIGH: "history has only an aggregate count, not locations"). Absent when the package has no
|
|
64
|
+
* README.md, or on an 'error' result where the sync never ran/mattered.
|
|
65
|
+
*/
|
|
66
|
+
readonly readmeSync?: {
|
|
67
|
+
readonly rewrittenLines: number;
|
|
68
|
+
readonly lines: readonly number[];
|
|
69
|
+
readonly skippedHistorical: number;
|
|
70
|
+
readonly historyLines: readonly number[];
|
|
71
|
+
} | undefined;
|
|
57
72
|
/**
|
|
58
73
|
* DRY-RUN ONLY, and the reason it exists is a measured incident. A dry run short-circuits
|
|
59
74
|
* BEFORE build, sign and pack (see the `opts.dryRun` branch below), so the package's own
|
|
@@ -501,15 +516,177 @@ export function orderByDependencies<T extends { name: string; dir: string }>(pkg
|
|
|
501
516
|
return ordered;
|
|
502
517
|
}
|
|
503
518
|
|
|
519
|
+
/** One README line the sync touched: 1-based line number, and the line before/after the rewrite. */
|
|
520
|
+
export interface ReadmeSyncRewrite {
|
|
521
|
+
readonly line: number;
|
|
522
|
+
readonly before: string;
|
|
523
|
+
readonly after: string;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** The report `planReadmeVersionSync` returns — never silent about what it did and did not touch. */
|
|
527
|
+
export interface ReadmeVersionSyncPlan {
|
|
528
|
+
readonly text: string;
|
|
529
|
+
readonly rewritten: readonly ReadmeSyncRewrite[];
|
|
530
|
+
/** Count of OLD-VERSION token OCCURRENCES left untouched as history (changelog region + every
|
|
531
|
+
* token outside every recognised ALLOWLIST shape — FR-1). */
|
|
532
|
+
readonly skippedHistorical: number;
|
|
533
|
+
/** The 1-based line numbers carrying at least one of those kept-as-history tokens (fix-round 1,
|
|
534
|
+
* Codex HIGH: "history has only an aggregate count, not locations; rewritten lines have
|
|
535
|
+
* numbers"). Deduplicated and sorted ascending — a line with two skipped tokens appears once. */
|
|
536
|
+
readonly historyLines: readonly number[];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* A line carrying this HTML comment opts BACK IN to rewriting, overriding both the changelog-region
|
|
541
|
+
* protection and the allowlist below — the author's explicit "this token is a stamp, not history"
|
|
542
|
+
* (AC-3).
|
|
543
|
+
*/
|
|
544
|
+
const DZ_VERSION_MARKER = '<!-- dz:version -->';
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Shape 4 of the allowlist (fix-round 1 design): the `dz publish` CLI's own example line, quoted
|
|
548
|
+
* verbatim in a README — `dz publish: tarball <name>@X sha256:…`. In practice every occurrence of
|
|
549
|
+
* this line is ALSO caught by shape 3 (the version always follows `<name>@`), so this predicate is
|
|
550
|
+
* mostly documentation of intent — named explicitly because the design brief calls it out as its own
|
|
551
|
+
* recognised shape, not an accident of shape 3's reach.
|
|
552
|
+
*/
|
|
553
|
+
// Codex r2 HIGH (lead): the tarball example line grants NO whole-line permission any more — its
|
|
554
|
+
// only stampable token is `<name>@X`, which shape 3 (install/pin) already recognises; a trailing
|
|
555
|
+
// `measured on X` on the same example line stays history.
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Shape 2 of the allowlist: a current-release FOOTER PREFIX — a short declarative label stamping the
|
|
559
|
+
* package's OWN current version (`Status: `, `Current release: `, `Current status: `, `Released as `),
|
|
560
|
+
* optionally preceded by a list marker or bold-open, with the label being the ENTIRE prefix up to the
|
|
561
|
+
* token — `Status: vX is current.` allows `vX` because nothing but the label sits before it. This is
|
|
562
|
+
* deliberately POSITION-AWARE (tested against the text before the token, not "does this line contain
|
|
563
|
+
* the word somewhere"): a line that opens with a footer label but cites an UNRELATED older version
|
|
564
|
+
* later in the same sentence — `Current release: 1.0.0. (Previous release (v1.1.0 / v1.0.0) …)` —
|
|
565
|
+
* must allow only the first token, not the second one sitting deep in a citation. `Previous release
|
|
566
|
+
* (vA / vB)` itself never matches at all: it opens with "Previous", not "release".
|
|
567
|
+
*/
|
|
568
|
+
// Codex r2 HIGH (lead): exactly the three settled footer labels, at line start, colon required —
|
|
569
|
+
// `Status:`, `Version:`, `Current release:` (optional bold / list marker). `Note: X`, `Released X`
|
|
570
|
+
// and every other label stay history.
|
|
571
|
+
// The settled label set (Codex r2 HIGH, lead): `Status:`, `Version:`, `Current release:`,
|
|
572
|
+
// `Current status:` (colon required, optional bold / list marker) and the original footer
|
|
573
|
+
// sentence `Released as vX` — the shapes the 2026-08-25 tests pin. `Note: X`, `Released X on …`,
|
|
574
|
+
// `Status as of X` and every other label are history.
|
|
575
|
+
const FOOTER_STAMP_PREFIX_RE = /^\s*(?:[-*+]\s+)?(?:(?:\*\*)?(?:status|version|current release|current status)(?::\*\*|\*\*:|:)|released as)\s*$/i;
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Shape 6 of the allowlist: a shields.io-style badge URL segment — `badge/npm-v0.7.7-…` or
|
|
579
|
+
* `badge/version-0.7.7-…`. Scoped to the literal `/badge/` marker (not a bare "-v" anywhere) so an
|
|
580
|
+
* unrelated hyphenated token elsewhere on the line is never mistaken for a badge.
|
|
581
|
+
*/
|
|
582
|
+
// Codex r2 HIGH (lead): a badge segment counts only inside a shields.io badge URL, not any `/badge/` path.
|
|
583
|
+
const BADGE_SEGMENT_RE = /img\.shields\.io\/badge\/[\w.%-]*$/i;
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Shape 3 of the allowlist: an install/dependency-pin context. Either the token is immediately
|
|
587
|
+
* preceded by `@` (`npm i @dzhechkov/harness-core@0.7.6`, the tarball example's `<name>@X`), or it
|
|
588
|
+
* sits in the JSON-pin shape `"<package-name>": "X"` (a `package.json`/lockfile-style dependency pin
|
|
589
|
+
* quoted in prose) — the design brief's "for the JSON-pin form accept `\": \"` before the token when
|
|
590
|
+
* the key is a package name".
|
|
591
|
+
*/
|
|
592
|
+
function isInstallPinContext(line: string, tokenStart: number): boolean {
|
|
593
|
+
// Codex r2 HIGH (lead): `@X` counts only as `<name>@X` — a package-name character must precede the
|
|
594
|
+
// `@` (`thing@0.8.25`, `@scope/name@0.8.25`); a bare `see @0.8.25` stays history.
|
|
595
|
+
if (line[tokenStart - 1] === '@' && /[A-Za-z0-9._-]/.test(line[tokenStart - 2] ?? '')) return true;
|
|
596
|
+
const before = line.slice(0, tokenStart);
|
|
597
|
+
return /"[@A-Za-z0-9][\w./-]*"\s*:\s*"$/.test(before);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* FR-1 (POSITIVE ALLOWLIST, not a denylist — Codex fix-round 1, 2026-09-15). Outside a changelog
|
|
602
|
+
* region, an old-version token occurrence rewrites ONLY when it sits in one of six recognised
|
|
603
|
+
* shapes — the lock-step feature this sync exists for, and NOTHING beyond it. Every shape NOT named
|
|
604
|
+
* here defaults to HISTORY, whatever prose it is written in: the previous design (a denylist of
|
|
605
|
+
* three named citation phrases — "on X", "X alike", "/ vX") was corruptible by construction, because
|
|
606
|
+
* ANY new prose shape citing the outgoing version ("since X", "measured against X", "X behaviour", a
|
|
607
|
+
* bare "X" in a sentence) rewrote by default until someone thought to deny it too. An allowlist has
|
|
608
|
+
* no such gap: an unrecognised shape is history by default, not by enumeration.
|
|
609
|
+
*
|
|
610
|
+
* 1. a release-line token — `` `harness-core vX` · `harness-cli vY` `` and any generalised
|
|
611
|
+
* `` `<name> vX` `` on the same line, including a trailing `` · `memory vZ` `` segment
|
|
612
|
+
* (release-line.ts `isReleaseLineToken`/`GENERIC_RELEASE_TOKEN_RE`).
|
|
613
|
+
* 2. a current-release FOOTER prefix (`FOOTER_STAMP_PREFIX_RE`) — position-aware, so only the
|
|
614
|
+
* token immediately after the label is allowed.
|
|
615
|
+
* 3. an install/dependency-pin context (`isInstallPinContext`).
|
|
616
|
+
* 4. the `dz publish: tarball <name>@X sha256:…` example line (`TARBALL_EXAMPLE_LINE_RE`).
|
|
617
|
+
* 5. (handled by the caller, not here) a `<!-- dz:version -->` marker forces the rewrite outright,
|
|
618
|
+
* overriding this predicate AND the changelog-region protection (AC-3).
|
|
619
|
+
* 6. a shields badge URL segment (`BADGE_SEGMENT_RE`).
|
|
620
|
+
*/
|
|
621
|
+
function isAllowlistedRewriteContext(line: string, tokenStart: number, versionEnd: number): boolean {
|
|
622
|
+
if (isReleaseLineToken(line, tokenStart, versionEnd)) return true; // shape 1
|
|
623
|
+
if (FOOTER_STAMP_PREFIX_RE.test(line.slice(0, tokenStart))) return true; // shape 2
|
|
624
|
+
if (isInstallPinContext(line, tokenStart)) return true; // shape 3 (also covers the tarball example's `<name>@X`)
|
|
625
|
+
if (BADGE_SEGMENT_RE.test(line.slice(0, tokenStart))) return true; // shape 6 (shields.io only)
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
|
|
504
629
|
/**
|
|
505
|
-
*
|
|
506
|
-
* version token (optionally `v`-prefixed, word-bounded) becomes the new one.
|
|
630
|
+
* Plan how a README's OLD-VERSION tokens would move to NEW-VERSION — a pure function, no I/O.
|
|
507
631
|
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
632
|
+
* FR-1 (allowlist, not denylist). Outside a changelog region (FR-2: EVERY entry-shaped run, not
|
|
633
|
+
* only the first — see `changelogRegion`), a token rewrites ONLY when `isAllowlistedRewriteContext`
|
|
634
|
+
* recognises its shape; every other token — historical prose of ANY form — is left untouched by
|
|
635
|
+
* default. A `<!-- dz:version -->` marker on the line forces the rewrite regardless of either
|
|
636
|
+
* protection (AC-3).
|
|
637
|
+
*
|
|
638
|
+
* FR-3 (never silent): every rewritten line is reported with its line number and before/after text;
|
|
639
|
+
* every token left untouched as history is counted AND located, whether the reason was the
|
|
640
|
+
* changelog region or simply not matching any allowlist shape.
|
|
641
|
+
*/
|
|
642
|
+
export function planReadmeVersionSync(
|
|
643
|
+
text: string,
|
|
644
|
+
oldVersion: string,
|
|
645
|
+
newVersion: string,
|
|
646
|
+
): ReadmeVersionSyncPlan {
|
|
647
|
+
const escaped = oldVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
648
|
+
const token = new RegExp(`(^|[^0-9A-Za-z.])(v?)${escaped}(?![0-9])(?!\\.[0-9])`, 'g');
|
|
649
|
+
const lines = text.split('\n');
|
|
650
|
+
const history = changelogRegion(lines);
|
|
651
|
+
const rewritten: ReadmeSyncRewrite[] = [];
|
|
652
|
+
const historyLineSet = new Set<number>();
|
|
653
|
+
let skippedHistorical = 0;
|
|
654
|
+
|
|
655
|
+
const outLines = lines.map((line, i) => {
|
|
656
|
+
const forced = line.includes(DZ_VERSION_MARKER);
|
|
657
|
+
const lineIsHistory = history.has(i) && !forced;
|
|
658
|
+
let touched = false;
|
|
659
|
+
const after = line.replace(token, (full: string, sep: string, vPrefix: string, offset: number) => {
|
|
660
|
+
const tokenStart = offset + sep.length; // includes the optional 'v' — allowlist shapes need it
|
|
661
|
+
const versionStart = tokenStart + vPrefix.length;
|
|
662
|
+
const versionEnd = versionStart + oldVersion.length;
|
|
663
|
+
const allowed = forced || (!lineIsHistory && isAllowlistedRewriteContext(line, tokenStart, versionEnd));
|
|
664
|
+
if (!allowed) {
|
|
665
|
+
skippedHistorical++;
|
|
666
|
+
historyLineSet.add(i + 1);
|
|
667
|
+
return full;
|
|
668
|
+
}
|
|
669
|
+
touched = true;
|
|
670
|
+
return `${sep}${vPrefix}${newVersion}`;
|
|
671
|
+
});
|
|
672
|
+
if (touched) rewritten.push({ line: i + 1, before: line, after });
|
|
673
|
+
return after;
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
return {
|
|
677
|
+
text: outLines.join('\n'),
|
|
678
|
+
rewritten,
|
|
679
|
+
skippedHistorical,
|
|
680
|
+
historyLines: [...historyLineSet].sort((a, b) => a - b),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Sync a package's own README to a freshly-bumped version — a thin, atomic-write wrapper around
|
|
686
|
+
* `planReadmeVersionSync`. Returns the pre-sync README text for failure restore, or undefined when
|
|
687
|
+
* nothing was rewritten (same contract as before this function grew a real plan underneath it —
|
|
688
|
+
* `dz publish`'s report reads the plan via `planReadmeVersionSync` directly; this wrapper's return
|
|
689
|
+
* value stays exactly what its callers already depend on).
|
|
513
690
|
*
|
|
514
691
|
* Bootstrap invariant: exact-token matching MAINTAINS sync but cannot REPAIR pre-existing drift
|
|
515
692
|
* (a footer already one release behind contains a token != oldVersion and is skipped). Bring the
|
|
@@ -519,18 +696,12 @@ export function syncReadmeVersion(dir: string, oldVersion: string, newVersion: s
|
|
|
519
696
|
const readmePath = join(dir, 'README.md');
|
|
520
697
|
if (!existsSync(readmePath)) return undefined;
|
|
521
698
|
const original = readFileSync(readmePath, 'utf-8');
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
const lines = original.split('\n');
|
|
525
|
-
const history = changelogRegion(lines);
|
|
526
|
-
const updated = lines
|
|
527
|
-
.map((line, i) => (history.has(i) ? line : line.replace(token, `$1$2${newVersion}`)))
|
|
528
|
-
.join('\n');
|
|
529
|
-
if (updated === original) return undefined;
|
|
699
|
+
const plan = planReadmeVersionSync(original, oldVersion, newVersion);
|
|
700
|
+
if (plan.text === original) return undefined;
|
|
530
701
|
// Atomic: a write interrupted after truncation would leave a half-written README in the tarball
|
|
531
702
|
// (cross-family review). temp + rename makes a partial file impossible.
|
|
532
703
|
const tmp = readmePath + '.sync-tmp';
|
|
533
|
-
writeFileSync(tmp,
|
|
704
|
+
writeFileSync(tmp, plan.text);
|
|
534
705
|
renameSync(tmp, readmePath);
|
|
535
706
|
return original;
|
|
536
707
|
}
|
|
@@ -601,21 +772,35 @@ function maskFences(lines: readonly string[]): string[] {
|
|
|
601
772
|
* The region ENDS at the next heading rather than at end-of-file on purpose: two of these READMEs
|
|
602
773
|
* carry ordinary sections after Status, and over-protecting them would silently stop the lock-step
|
|
603
774
|
* sync where it is still wanted.
|
|
775
|
+
*
|
|
776
|
+
* MEASURED 2026-09-15 (00_complexity_assessment.md, feature publish-readme-stamp-scope): this
|
|
777
|
+
* function protected only the FIRST such run. A second `## Status` heading further down the SAME
|
|
778
|
+
* README opens a SECOND entry-shaped run (`memory` 0.2.21/0.2.22 sat under a later `## Status`,
|
|
779
|
+
* after an earlier `0.1.0` entry whose region had already ended) — and that second run was bare,
|
|
780
|
+
* so its entries got relabelled by the next bump exactly like the 2026-08-25 incident this function
|
|
781
|
+
* was written to stop. FR-2: EVERY entry-shaped run in the document is protected, not only the
|
|
782
|
+
* first — the scan restarts after each run ends instead of stopping there.
|
|
604
783
|
*/
|
|
605
784
|
export function changelogRegion(lines: readonly string[]): Set<number> {
|
|
606
785
|
const out = new Set<number>();
|
|
607
786
|
const masked = maskFences(lines);
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
787
|
+
let i = 0;
|
|
788
|
+
while (i < masked.length) {
|
|
789
|
+
if (!ANY_ENTRY.test(masked[i] as string)) { i++; continue; }
|
|
790
|
+
const start = i;
|
|
791
|
+
// REJECTED design, recorded so it is not retried: "sync the FIRST entry, protect the rest". It
|
|
792
|
+
// looks like it restores the lock-step for the current release, and it is unsafe in exactly the
|
|
793
|
+
// case that produced the bug — an author who bumps WITHOUT adding a new entry has the previous
|
|
794
|
+
// release's entry sitting first, and syncing it relabels that release's contents to the new
|
|
795
|
+
// version. The whole region stays protected; writing the newest heading is the author's job, and
|
|
796
|
+
// the prompt for it is that the version they type is the version they are about to publish.
|
|
797
|
+
while (i < masked.length && !(i > start && REGION_END.test(masked[i] as string))) {
|
|
798
|
+
out.add(i);
|
|
799
|
+
i++;
|
|
800
|
+
}
|
|
801
|
+
// `i` now sits on the heading that ended this run (or at EOF) — NOT consumed, so the outer loop
|
|
802
|
+
// re-examines it: a heading is never itself an entry, but the very next line under it can open a
|
|
803
|
+
// brand-new run, which is exactly the second-`## Status` case above.
|
|
619
804
|
}
|
|
620
805
|
return out;
|
|
621
806
|
}
|
|
@@ -734,6 +919,9 @@ export function publishPackages(
|
|
|
734
919
|
readonly readmePath: string;
|
|
735
920
|
readonly originalReadme: string | undefined;
|
|
736
921
|
readonly claimCheckSummary: { findings: number; high: number } | undefined;
|
|
922
|
+
/** FR-3 (fix-round 1): carried through the two-pass packed transport so the readme-sync report
|
|
923
|
+
* reaches the FINAL 'published'/'error' result too — not only the pass-1 optimistic entry. */
|
|
924
|
+
readonly readmeSyncSummary: PublishResult['readmeSync'];
|
|
737
925
|
}
|
|
738
926
|
const pendingPacked: PendingPacked[] = [];
|
|
739
927
|
|
|
@@ -879,6 +1067,27 @@ export function publishPackages(
|
|
|
879
1067
|
}
|
|
880
1068
|
}
|
|
881
1069
|
|
|
1070
|
+
// FR-3 (feature publish-readme-stamp-scope): preview the README sync BEFORE the dry-run
|
|
1071
|
+
// short-circuit, so `--dry-run` shows what the live sync would do — never silent about it, the
|
|
1072
|
+
// same reasoning as the claim-check gate just above. Reading the README never blocks publish;
|
|
1073
|
+
// an unreadable README simply carries no readmeSync summary.
|
|
1074
|
+
let readmeSyncSummary: PublishResult['readmeSync'];
|
|
1075
|
+
try {
|
|
1076
|
+
const readmePath = join(pkg.dir, 'README.md');
|
|
1077
|
+
if (existsSync(readmePath)) {
|
|
1078
|
+
const text = readFileSync(readmePath, 'utf-8');
|
|
1079
|
+
const plan = planReadmeVersionSync(text, oldVersion, newVersion);
|
|
1080
|
+
readmeSyncSummary = {
|
|
1081
|
+
rewrittenLines: plan.rewritten.length,
|
|
1082
|
+
lines: plan.rewritten.map((r) => r.line),
|
|
1083
|
+
skippedHistorical: plan.skippedHistorical,
|
|
1084
|
+
historyLines: plan.historyLines,
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
} catch {
|
|
1088
|
+
/* unreadable README never blocks publish or this preview */
|
|
1089
|
+
}
|
|
1090
|
+
|
|
882
1091
|
if (opts.dryRun) {
|
|
883
1092
|
// NOT a statement that the package would publish cleanly — only that the gates checked ABOVE
|
|
884
1093
|
// this line passed. Everything below it (build, re-sign, pack, the package's own
|
|
@@ -893,6 +1102,7 @@ export function publishPackages(
|
|
|
893
1102
|
results.push({
|
|
894
1103
|
name: pkg.name, oldVersion, newVersion, status: 'skipped',
|
|
895
1104
|
claimCheck: claimCheckSummary,
|
|
1105
|
+
readmeSync: readmeSyncSummary,
|
|
896
1106
|
notVerified: NOT_VERIFIED_BY_DRY_RUN,
|
|
897
1107
|
});
|
|
898
1108
|
landedInBatch.add(pkg.name);
|
|
@@ -909,7 +1119,12 @@ export function publishPackages(
|
|
|
909
1119
|
originalReadme = syncReadmeVersion(pkg.dir, oldVersion, newVersion);
|
|
910
1120
|
|
|
911
1121
|
if (opts.bumpOnly) {
|
|
912
|
-
|
|
1122
|
+
// FR-3 fix-round 1 (Codex HIGH): the readme-sync summary was previously attached only to the
|
|
1123
|
+
// dry-run and main-live paths — --bump-only silently omitted it even though `syncReadmeVersion`
|
|
1124
|
+
// just ran two lines above. Reuse the SAME preview computed before the dry-run branch: it is a
|
|
1125
|
+
// pure function of the same pre-sync text and the same old/new versions, so it already
|
|
1126
|
+
// describes exactly what the write above just did.
|
|
1127
|
+
results.push({ name: pkg.name, oldVersion, newVersion, status: 'published', claimCheck: claimCheckSummary, readmeSync: readmeSyncSummary });
|
|
913
1128
|
continue;
|
|
914
1129
|
}
|
|
915
1130
|
|
|
@@ -1037,6 +1252,7 @@ export function publishPackages(
|
|
|
1037
1252
|
readmePath: pathJoin(pkg.dir, 'README.md'),
|
|
1038
1253
|
originalReadme,
|
|
1039
1254
|
claimCheckSummary,
|
|
1255
|
+
readmeSyncSummary,
|
|
1040
1256
|
});
|
|
1041
1257
|
pinVersions.set(pkg.name, newVersion);
|
|
1042
1258
|
// Optimistic, mirroring the dry-run branch above: this package WILL land once the
|
|
@@ -1062,7 +1278,7 @@ export function publishPackages(
|
|
|
1062
1278
|
|
|
1063
1279
|
const { registryProbes } = confirmPublished(pkg.name, newVersion, probeLog);
|
|
1064
1280
|
|
|
1065
|
-
results.push({ name: pkg.name, oldVersion, newVersion, status: 'published', registryProbes, probeLog, claimCheck: claimCheckSummary });
|
|
1281
|
+
results.push({ name: pkg.name, oldVersion, newVersion, status: 'published', registryProbes, probeLog, claimCheck: claimCheckSummary, readmeSync: readmeSyncSummary });
|
|
1066
1282
|
landedInBatch.add(pkg.name); // only an ACTUAL publish covers dependents (Codex P1)
|
|
1067
1283
|
} catch (err) {
|
|
1068
1284
|
// The version was written BEFORE build+publish; on any failure restore the
|
|
@@ -1182,6 +1398,7 @@ export function publishPackages(
|
|
|
1182
1398
|
results.push({
|
|
1183
1399
|
name: p.name, oldVersion: p.oldVersion, newVersion: p.newVersion, status: 'published',
|
|
1184
1400
|
registryProbes, probeLog, claimCheck: p.claimCheckSummary, sha256: p.sha256,
|
|
1401
|
+
readmeSync: p.readmeSyncSummary, // FR-3 fix-round 1: the third publish path that was silent
|
|
1185
1402
|
});
|
|
1186
1403
|
// landedInBatch already carries p.name from pass 1 (optimistic) — now confirmed for real.
|
|
1187
1404
|
} catch (err) {
|
package/src/release-line.ts
CHANGED
|
@@ -29,3 +29,35 @@ export function rewriteReleaseLine(text: string, core: string, cli: string): str
|
|
|
29
29
|
);
|
|
30
30
|
return lines.join('\n');
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A generic RELEASE-LINE token: a backtick-quoted `<pkg-short-name> vX` pair, anywhere on a line —
|
|
35
|
+
* the shape `RELEASE_LINE_RE` names for the joint `harness-core`/`harness-cli` pair, generalised to
|
|
36
|
+
* ANY package name (feature publish-readme-stamp-scope, FR-1a) so a per-package README's own status
|
|
37
|
+
* line — `` `harness-core vX` · `harness-cli vY` · `memory vZ` `` and similar — is recognised as a
|
|
38
|
+
* release-line shape whatever packages it names, not only the original two, and however many trail
|
|
39
|
+
* after the first pair (an extra `` · `memory vZ` `` segment needs no bespoke regex of its own).
|
|
40
|
+
*/
|
|
41
|
+
export const GENERIC_RELEASE_TOKEN_RE = /`[a-z][a-z0-9-]*\s+v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?`/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Is the OLD-VERSION occurrence at `[start, end)` in `line` sitting inside a `` `<name> vX` ``
|
|
45
|
+
* backtick token? A POSITIVE override for `planReadmeVersionSync`'s citation heuristic: a token
|
|
46
|
+
* this shape matches is a release-line stamp, never a historical citation, even where it sits next
|
|
47
|
+
* to punctuation ("/", "on ") the citation heuristic would otherwise read as a citation cue.
|
|
48
|
+
*/
|
|
49
|
+
export function isReleaseLineToken(line: string, start: number, end: number): boolean {
|
|
50
|
+
// Codex r2 HIGH (lead): a `` `<name> vX` `` token is a release-line stamp ONLY on a line that carries
|
|
51
|
+
// the JOINT release-line shape (`RELEASE_LINE_RE`); an isolated `` `memory v0.8.25` `` in a
|
|
52
|
+
// historical sentence is history and must not move.
|
|
53
|
+
// Codex r3 HIGH (lead): the permission is the joint pair PLUS the CONTIGUOUS ` · `<name> vX``
|
|
54
|
+
// chain that follows it — not the whole line. `` `harness-core vX` · `harness-cli vY` — historically
|
|
55
|
+
// `memory vZ` `` moves the first two and keeps the third (prose broke the chain).
|
|
56
|
+
const joint = RELEASE_LINE_RE.exec(line);
|
|
57
|
+
if (joint === null) return false;
|
|
58
|
+
const chainTail = new RegExp(`(?:\\s*·\\s*${GENERIC_RELEASE_TOKEN_RE.source})*`, 'y');
|
|
59
|
+
chainTail.lastIndex = joint.index + joint[0].length;
|
|
60
|
+
const tail = chainTail.exec(line);
|
|
61
|
+
const chainEnd = joint.index + joint[0].length + (tail?.[0].length ?? 0);
|
|
62
|
+
return joint.index <= start && end <= chainEnd;
|
|
63
|
+
}
|
package/src/vector-tier.ts
CHANGED
|
@@ -1158,13 +1158,87 @@ export interface RankedPattern {
|
|
|
1158
1158
|
|
|
1159
1159
|
const RRF_K = 60;
|
|
1160
1160
|
|
|
1161
|
+
/**
|
|
1162
|
+
* One entry in the total order every post-merge ranking step shares (feature
|
|
1163
|
+
* `recall-parity-tie-break`, FR-1). `evidence` is the SAME three-way rank `mergeHybridHits` has
|
|
1164
|
+
* always used (`both` < lexical-only < semantic-only — lower is stronger), computed once by
|
|
1165
|
+
* {@link evidenceRank} from a hit's `backend`.
|
|
1166
|
+
*/
|
|
1167
|
+
export interface HybridOrderKey {
|
|
1168
|
+
readonly score: number;
|
|
1169
|
+
readonly evidence: number;
|
|
1170
|
+
readonly dzId: string;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** `both` outranks lexical-only outranks semantic-only (mergeHybridHits' own rule, ADR-001 AM-4). */
|
|
1174
|
+
export function evidenceRank(backend: RecallHit['backend']): number {
|
|
1175
|
+
return backend === 'both' ? 0 : backend === 'vector' ? 2 : 1;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* The ONE deterministic total order recall uses at every step where a tie can occur: fused score
|
|
1180
|
+
* DESC, then EVIDENCE (both > lexical-only > semantic-only), then `dzId` ASC. `mergeHybridHits`
|
|
1181
|
+
* always applied exactly this rule inline; it is exported here (FR-1) so `dampQuarantined`,
|
|
1182
|
+
* `orderHitsForReRank` (the pre-sort `enhance()` runs before its reinforcement/bandit re-rank) and
|
|
1183
|
+
* any other post-merge sort can share the SAME tiebreak instead of an ad hoc score-only comparator
|
|
1184
|
+
* that is deterministic only because its input already arrived pre-ordered — a property that
|
|
1185
|
+
* silently breaks the moment an upstream step feeds it hits in a different order
|
|
1186
|
+
* (recall-parity-tie-break T0: MEASURED, `apply-leg-recall-parity.test.ts` AM-4, a racy
|
|
1187
|
+
* reinforcement-signal read, not a comparator defect, actually explained the observed tail swap —
|
|
1188
|
+
* this comparator is hardening kept from that round; the actual causal fix, per fix-round 1, is the
|
|
1189
|
+
* byte-level store snapshot AM-4 now takes, not this comparator and not an awaited flush).
|
|
1190
|
+
*
|
|
1191
|
+
* FINITE-NUMBER INVARIANT (fix-round 1, LOW finding): plain subtraction (`b.score - a.score`) is
|
|
1192
|
+
* NOT total over `number` — `NaN - x` is `NaN`, and the `||` chain treats a `NaN` term as falsy,
|
|
1193
|
+
* silently SKIPPING it and falling through to the next key as if score had never been compared.
|
|
1194
|
+
* Both terms below use explicit `>`/`<` comparisons instead (correct as-is for ±Infinity — IEEE 754
|
|
1195
|
+
* orders infinities correctly) plus an explicit NaN case: a `NaN` score or evidence is the WEAKEST
|
|
1196
|
+
* possible value on its own axis, so it sorts deterministically LAST, never a coincidental tie.
|
|
1197
|
+
*/
|
|
1198
|
+
function compareScoreDesc(a: number, b: number): number {
|
|
1199
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return Number.isNaN(a) && Number.isNaN(b) ? 0 : Number.isNaN(a) ? 1 : -1;
|
|
1200
|
+
return a > b ? -1 : a < b ? 1 : 0;
|
|
1201
|
+
}
|
|
1202
|
+
function compareEvidenceAsc(a: number, b: number): number {
|
|
1203
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return Number.isNaN(a) && Number.isNaN(b) ? 0 : Number.isNaN(a) ? 1 : -1;
|
|
1204
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1205
|
+
}
|
|
1206
|
+
export function compareHybridHits(a: HybridOrderKey, b: HybridOrderKey): number {
|
|
1207
|
+
return compareScoreDesc(a.score, b.score)
|
|
1208
|
+
|| compareEvidenceAsc(a.evidence, b.evidence)
|
|
1209
|
+
|| (a.dzId < b.dzId ? -1 : a.dzId > b.dzId ? 1 : 0);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Sorts `hits` into the shared total order {@link compareHybridHits} defines — used by `enhance()`
|
|
1214
|
+
* BEFORE its reinforcement/bandit re-rank runs (`applyLearningSignalsWithTerms` et al.,
|
|
1215
|
+
* `learning-backend.ts`, out of this fix's edit scope). That re-rank sorts by an ADJUSTED score
|
|
1216
|
+
* with a STABLE tie-break on each hit's ORIGINAL array position — so pre-ordering the input here
|
|
1217
|
+
* makes any tie in the adjusted score resolve in the SAME evidence/dzId order `compareHybridHits`
|
|
1218
|
+
* would give directly, without touching the re-rank's own internals.
|
|
1219
|
+
*
|
|
1220
|
+
* This closes the ordering gap `enhance()` had (recall-parity-tie-break fix-round 1, HIGH finding):
|
|
1221
|
+
* `dampQuarantined` only ran {@link compareHybridHits} when `memory.learning.quarantine` was ON;
|
|
1222
|
+
* with it OFF (the default), `enhance()`'s final order was whatever the re-rank's own
|
|
1223
|
+
* original-index tie-break happened to preserve — invisible from the printed `score` column,
|
|
1224
|
+
* because the re-rank reorders the hit array but never rewrites `.score`. Real (non-tied) score
|
|
1225
|
+
* differences from reinforcement/bandit re-ranking are UNCHANGED by this — it only decides ties.
|
|
1226
|
+
*/
|
|
1227
|
+
export function orderHitsForReRank(hits: readonly HybridHit[], idOf: (p: PatternRecord) => string): HybridHit[] {
|
|
1228
|
+
return [...hits].sort((a, b) => compareHybridHits(
|
|
1229
|
+
{ score: a.score, evidence: evidenceRank(a.backend), dzId: idOf(a.pattern) },
|
|
1230
|
+
{ score: b.score, evidence: evidenceRank(b.backend), dzId: idOf(b.pattern) },
|
|
1231
|
+
));
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1161
1234
|
/**
|
|
1162
1235
|
* Reciprocal Rank Fusion merge: `score(p) = Σ 1/(60 + rank)` over the lists containing `p`
|
|
1163
1236
|
* (semantic ranks weighted by `semanticWeight`). Dedup by id; `backend: 'both'` when a pattern
|
|
1164
1237
|
*
|
|
1165
|
-
* Ordering: fused score, then EVIDENCE (`both` before lexical-only before semantic-only), then id
|
|
1166
|
-
* When `semanticWeight > 1` the lexical top-1 is guaranteed a place in
|
|
1167
|
-
* last seat unless that seat holds a `both` hit. See
|
|
1238
|
+
* Ordering: fused score, then EVIDENCE (`both` before lexical-only before semantic-only), then id
|
|
1239
|
+
* — {@link compareHybridHits}. When `semanticWeight > 1` the lexical top-1 is guaranteed a place in
|
|
1240
|
+
* the result, taken from the last seat unless that seat holds a `both` hit. See
|
|
1241
|
+
* `features/semantic-keeps-exact-hits`.
|
|
1168
1242
|
*
|
|
1169
1243
|
* appears in both lists. DETERMINISTIC (AC-6): ties break on id, so fixed inputs always yield
|
|
1170
1244
|
* the same ordering. Pure — no I/O.
|
|
@@ -1196,12 +1270,14 @@ export function mergeHybridHits(
|
|
|
1196
1270
|
});
|
|
1197
1271
|
// Ties break by EVIDENCE, not by the id alphabet: a hit both legs found outranks one only a single
|
|
1198
1272
|
// leg found. Before this, an exact-term match lost a tie to an arbitrary semantic hit purely
|
|
1199
|
-
// because its id sorted later (ADR-001 AM-4).
|
|
1200
|
-
|
|
1273
|
+
// because its id sorted later (ADR-001 AM-4). Now routed through the shared {@link
|
|
1274
|
+
// compareHybridHits} (FR-1) — same three-part rule, no behaviour change.
|
|
1275
|
+
const evidenceOfAcc = (v: Acc): number => evidenceRank(v.lex !== undefined && v.sem ? 'both' : v.lex ?? 'vector');
|
|
1201
1276
|
const ordered = [...acc.entries()]
|
|
1202
|
-
.sort((a, b) =>
|
|
1203
|
-
|
|
1204
|
-
|
|
1277
|
+
.sort((a, b) => compareHybridHits(
|
|
1278
|
+
{ score: a[1].score, evidence: evidenceOfAcc(a[1]), dzId: a[0] },
|
|
1279
|
+
{ score: b[1].score, evidence: evidenceOfAcc(b[1]), dzId: b[0] },
|
|
1280
|
+
));
|
|
1205
1281
|
const toHit = ([, v]: [string, Acc]): HybridHit => ({
|
|
1206
1282
|
pattern: v.pattern,
|
|
1207
1283
|
backend: v.lex !== undefined && v.sem ? ('both' as const) : v.lex ?? ('vector' as const),
|
|
@@ -1256,6 +1332,27 @@ export function mergeHybridHits(
|
|
|
1256
1332
|
*
|
|
1257
1333
|
* `bandit` is passed ONLY when `memory.learning.banditRerank` is armed; when it is absent this
|
|
1258
1334
|
* function is byte-identical to its pre-feature self — no state file, no lock, no allocation.
|
|
1335
|
+
*
|
|
1336
|
+
* `backend.train()` fires FIRE-AND-FORGET (`void backend.train().catch(() => undefined)`) — this is
|
|
1337
|
+
* a REVERT (recall-parity-tie-break, fix-round 1, MEDIUM finding). An intermediate version of this
|
|
1338
|
+
* fix AWAITED the flush, on the theory that a caller's own reinforcement write landing before it got
|
|
1339
|
+
* an answer would remove the race `apply-leg-recall-parity.test.ts` AM-4 was catching (two tail hits
|
|
1340
|
+
* swapping order between the daemon's `op:recall` reply and a `dz recall --json` invoked a moment
|
|
1341
|
+
* later — MEASURED byte-identical `score` fields, only `uses` differed, so the merge/comparator was
|
|
1342
|
+
* never the cause). MEASURED (lead, 2026-09-15 15:57, temp project, 8 lexical hits, 5 warm runs):
|
|
1343
|
+
* `recallHybrid` took 2 ms with `onRecallHits:false` (no flush at all) vs 57–131 ms with the flush
|
|
1344
|
+
* AWAITED — landing INSIDE the hook's 500 ms `HOOK_RECALL_BUDGET_MS` but a real, avoidable tax on
|
|
1345
|
+
* every recall, for a property the await did not even fully deliver: the awaited write still let a
|
|
1346
|
+
* CLI invoked immediately after the daemon see the DAEMON'S OWN just-computed exposure for that same
|
|
1347
|
+
* query, answering a subtly different question than the daemon had. AM-4 now proves parity by taking
|
|
1348
|
+
* a byte-level SNAPSHOT of the store BEFORE each query's daemon call and pointing `dz recall --json`
|
|
1349
|
+
* at the frozen snapshot (`--project <snapshot>`) — both sides then answer the identical question
|
|
1350
|
+
* from the identical state under PRODUCTION defaults (`onRecallHits` ON), and no write, awaited or
|
|
1351
|
+
* not, can reach the CLI's read. That snapshot is what actually closes the race; this function stays
|
|
1352
|
+
* fire-and-forget, exactly as it always was, because the snapshot makes its timing irrelevant to the
|
|
1353
|
+
* test. `test/lesson-bandit-byte-identity.test.ts` documents the same underlying race in its own
|
|
1354
|
+
* fixture comment and works around it with `onRecallHits: false` there — a narrower, still-valid
|
|
1355
|
+
* isolation for a different test's needs.
|
|
1259
1356
|
*/
|
|
1260
1357
|
function markRecallHits(
|
|
1261
1358
|
projectRoot: string,
|
|
@@ -1356,7 +1453,15 @@ export async function recallHybrid(
|
|
|
1356
1453
|
const q = rec !== undefined && readQuarantineState(rec).quarantined;
|
|
1357
1454
|
return q ? { ...h, score: h.score * memCfg.quarantineDamp, quarantined: true as const } : h;
|
|
1358
1455
|
})
|
|
1359
|
-
|
|
1456
|
+
// FR-1 (recall-parity-tie-break): score-only used to rely on the INCOMING array already
|
|
1457
|
+
// being pre-ordered (native Array.sort is stable, so a genuine tie only stayed put by
|
|
1458
|
+
// accident of arrival order). Routed through the same {@link compareHybridHits} the merge
|
|
1459
|
+
// uses, so damping two equally-scored hits can never reorder them differently from how the
|
|
1460
|
+
// merge itself would have.
|
|
1461
|
+
.sort((a, b) => compareHybridHits(
|
|
1462
|
+
{ score: a.score, evidence: evidenceRank(a.backend), dzId: idOf(a.pattern) },
|
|
1463
|
+
{ score: b.score, evidence: evidenceRank(b.backend), dzId: idOf(b.pattern) },
|
|
1464
|
+
));
|
|
1360
1465
|
};
|
|
1361
1466
|
// lesson-bandit-rerank (ADR-001): the payoff axis. Resolved ONCE per recall; `enabled:false` ⇒
|
|
1362
1467
|
// the Lesson Payoff context is NEVER CONSTRUCTED — the branch is taken BEFORE any work, so the
|
|
@@ -1366,7 +1471,15 @@ export async function recallHybrid(
|
|
|
1366
1471
|
let banditReport: BanditRecallReport | undefined;
|
|
1367
1472
|
let banditExplored: readonly string[] = [];
|
|
1368
1473
|
const enhance = (hits: readonly HybridHit[]): HybridHit[] => {
|
|
1369
|
-
|
|
1474
|
+
// FR-1 (recall-parity-tie-break, fix-round 1, HIGH finding): pre-sort into the shared total
|
|
1475
|
+
// order BEFORE the reinforcement/bandit re-rank runs — see {@link orderHitsForReRank}.
|
|
1476
|
+
// Why the pre-sort is sufficient and not "reliance on a stable sort" (Codex round 2): the
|
|
1477
|
+
// re-rank's own sort in learning-backend.ts is `b.adjusted - a.adjusted || a.i - b.i` — an
|
|
1478
|
+
// EXPLICIT tie-break on the incoming index, so an adjusted-score tie resolves to exactly the
|
|
1479
|
+
// order built here (score → evidence → dzId), by construction, on any engine. That generic
|
|
1480
|
+
// function only knows `score`, so it cannot call compareHybridHits itself.
|
|
1481
|
+
const ordered = orderHitsForReRank(hits, idOf);
|
|
1482
|
+
const candidates = ordered.map((h) => {
|
|
1370
1483
|
const dzId = idOf(h.pattern);
|
|
1371
1484
|
const rec = idToRecord.get(dzId);
|
|
1372
1485
|
return { dzId, score: h.score, reinforcement: rec !== undefined ? readReinforcementState(rec) : undefined };
|
|
@@ -1392,8 +1505,8 @@ export async function recallHybrid(
|
|
|
1392
1505
|
? []
|
|
1393
1506
|
: [{ id: 'delta', byIndex: candidates.map((c) => deltaMap.get(c.dzId) ?? 0), cap: REINFORCE_RRF_CAP }];
|
|
1394
1507
|
// The SAME ranking without the payoff term — the only honest way to say what the term moved.
|
|
1395
|
-
const before = dampQuarantined(applyLearningSignalsWithTerms(
|
|
1396
|
-
const after = dampQuarantined(applyLearningSignalsWithTerms(
|
|
1508
|
+
const before = dampQuarantined(applyLearningSignalsWithTerms(ordered, learning, candidates, REINFORCE_RRF_CAP, baseTerms));
|
|
1509
|
+
const after = dampQuarantined(applyLearningSignalsWithTerms(ordered, learning, candidates, REINFORCE_RRF_CAP, [
|
|
1397
1510
|
...baseTerms,
|
|
1398
1511
|
// ADDED, never assigned, and pre-bounded to [-1,+1] by the ACL — so `squash` is identity and
|
|
1399
1512
|
// `cap` is an EXACT bound on this term's contribution (INV-4).
|
|
@@ -1423,9 +1536,9 @@ export async function recallHybrid(
|
|
|
1423
1536
|
}
|
|
1424
1537
|
if (deltaMap !== undefined) {
|
|
1425
1538
|
const deltaByIndex = candidates.map((c) => deltaMap.get(c.dzId) ?? 0);
|
|
1426
|
-
return dampQuarantined(applyLearningSignalsWithDelta(
|
|
1539
|
+
return dampQuarantined(applyLearningSignalsWithDelta(ordered, learning, candidates, REINFORCE_RRF_CAP, deltaByIndex, REINFORCE_RRF_CAP));
|
|
1427
1540
|
}
|
|
1428
|
-
return dampQuarantined(applyLearningSignals(
|
|
1541
|
+
return dampQuarantined(applyLearningSignals(ordered, learning, candidates, REINFORCE_RRF_CAP));
|
|
1429
1542
|
};
|
|
1430
1543
|
/** The exposure/telemetry payload for `markRecallHits` — `undefined` while disarmed (INV-1). */
|
|
1431
1544
|
const banditEmission = (): { readonly contextKey: string; readonly explored: readonly string[]; readonly moved: number; readonly arms: number; readonly deferred?: boolean } | undefined =>
|