Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules/
dist/
coverage/
*.log
package-lock.json
187 changes: 92 additions & 95 deletions core/HotpathPolicy.ts
Original file line number Diff line number Diff line change
@@ -1,141 +1,137 @@
import type { TierQuotas } from "./types";
// ---------------------------------------------------------------------------
// HotpathPolicy — Williams Bound policy foundation
// ---------------------------------------------------------------------------
//
// Central source of truth for the Williams Bound architecture.
// All hotpath constants live here as a frozen default policy object.
// Policy-derived != model-derived — kept strictly separate from ModelDefaults.
// ---------------------------------------------------------------------------

import type { SalienceWeights, TierQuotaRatios, TierQuotas } from "./types";

// ---------------------------------------------------------------------------
// Weight / ratio parameter types
// HotpathPolicy interface
// ---------------------------------------------------------------------------

export interface SalienceWeights {
alpha: number; // Hebbian in-degree weight
beta: number; // recency weight
gamma: number; // query-hit weight
}
export interface HotpathPolicy {
/** Scaling factor in H(t) = ceil(c * sqrt(t * log2(1+t))) */
readonly c: number;

/** Salience weights: sigma = alpha*H_in + beta*R + gamma*Q */
readonly salienceWeights: SalienceWeights;

export interface TierQuotaRatios {
shelf: number;
volume: number;
book: number;
page: number;
/** Fractional tier quota ratios (must sum to 1.0) */
readonly tierQuotaRatios: TierQuotaRatios;
}

// ---------------------------------------------------------------------------
// Frozen default policy constants
// Frozen default policy object
// ---------------------------------------------------------------------------

export const DEFAULT_HOTPATH_POLICY = Object.freeze({
/** Capacity scaling constant. */
export const DEFAULT_HOTPATH_POLICY: HotpathPolicy = Object.freeze({
c: 0.5,
/** Hebbian in-degree weight (α). */
alpha: 0.5,
/** Recency weight (β). */
beta: 0.3,
/** Query-hit weight (γ). */
gamma: 0.2,
/** Tier quota ratios. */
q_s: 0.10,
q_v: 0.20,
q_b: 0.20,
q_p: 0.50,
salienceWeights: Object.freeze({
alpha: 0.5, // Hebbian connectivity
beta: 0.3, // recency
gamma: 0.2, // query-hit frequency
}),
tierQuotaRatios: Object.freeze({
shelf: 0.10,
volume: 0.20,
book: 0.20,
page: 0.50,
}),
});

// ---------------------------------------------------------------------------
// computeCapacity — H(t) = ⌈c · √(t · log₂(1+t))⌉
// H(t) — Resident hotpath capacity
// ---------------------------------------------------------------------------

/**
* Williams Bound capacity function.
* Compute the resident hotpath capacity H(t) = ceil(c * sqrt(t * log2(1+t))).
*
* Returns an integer ≥ 1 for any non-negative finite `graphMass`.
* For `graphMass === 0` the inner product is 0, so ⌈0⌉ = 0, but we clamp to 1
* to guarantee at least one hotpath slot is always available.
* Properties guaranteed by tests:
* - Monotonically non-decreasing
* - Sublinear growth (H(t)/t shrinks as t grows)
* - Returns a finite integer >= 1 for any non-negative finite t
*/
export function computeCapacity(graphMass: number): number {
const c = DEFAULT_HOTPATH_POLICY.c;
const t = Math.max(0, graphMass);

if (!Number.isFinite(t)) {
// Handle Infinity / NaN — return a safe large integer
return Number.MAX_SAFE_INTEGER;
export function computeCapacity(
graphMass: number,
c: number = DEFAULT_HOTPATH_POLICY.c,
): number {
if (!Number.isFinite(graphMass) || graphMass < 0) {
return 1;
}
if (graphMass === 0) return 1;

const log2 = Math.log2(1 + t);
const inner = t * log2;
const raw = c * Math.sqrt(inner);

if (!Number.isFinite(raw)) {
return Number.MAX_SAFE_INTEGER;
}
const log2 = Math.log2(1 + graphMass);
const raw = c * Math.sqrt(graphMass * log2);

return Math.max(1, Math.ceil(raw));
if (!Number.isFinite(raw) || raw < 1) return 1;
return Math.ceil(raw);
}

// ---------------------------------------------------------------------------
// computeSalience σ = α·H_in + β·R + γ·Q
// Node saliencesigma = alpha*H_in + beta*R + gamma*Q
// ---------------------------------------------------------------------------

/**
* Computes salience score for a hotpath candidate.
* Compute node salience: sigma = alpha*H_in + beta*R + gamma*Q.
*
* Always returns a finite number. Inputs that produce `NaN` or `Infinity` are
* clamped to `0`.
* @param hebbianIn Sum of incident Hebbian edge weights
* @param recency Recency score (0-1, exponential decay)
* @param queryHits Query-hit count for the node
* @param weights Tunable weights (default from policy)
*/
export function computeSalience(
hebbianIn: number,
recency: number,
queryHits: number,
weights?: SalienceWeights,
weights: SalienceWeights = DEFAULT_HOTPATH_POLICY.salienceWeights,
): number {
const α = weights?.alpha ?? DEFAULT_HOTPATH_POLICY.alpha;
const β = weights?.beta ?? DEFAULT_HOTPATH_POLICY.beta;
const γ = weights?.gamma ?? DEFAULT_HOTPATH_POLICY.gamma;

const raw = α * hebbianIn + β * recency + γ * queryHits;
const raw = weights.alpha * hebbianIn
+ weights.beta * recency
+ weights.gamma * queryHits;

if (!Number.isFinite(raw)) return 0;
return raw;
}

// ---------------------------------------------------------------------------
// deriveTierQuotas — allocate H(t) across tiers
// Tier quota derivation
// ---------------------------------------------------------------------------

/**
* Distributes `capacity` slots across four tiers according to `quotaRatios`.
* Allocate H(t) across shelf/volume/book/page tiers.
*
* The distribution uses a largest-remainder method so the integer counts
* always sum **exactly** to `capacity`.
* Uses largest-remainder method so quotas sum exactly to `capacity`.
*/
export function deriveTierQuotas(
capacity: number,
quotaRatios?: TierQuotaRatios,
ratios: TierQuotaRatios = DEFAULT_HOTPATH_POLICY.tierQuotaRatios,
): TierQuotas {
const ratios = quotaRatios ?? {
shelf: DEFAULT_HOTPATH_POLICY.q_s,
volume: DEFAULT_HOTPATH_POLICY.q_v,
book: DEFAULT_HOTPATH_POLICY.q_b,
page: DEFAULT_HOTPATH_POLICY.q_p,
};
const tiers: (keyof TierQuotas)[] = ["shelf", "volume", "book", "page"];

const cap = Math.max(0, Math.floor(capacity));
const keys: (keyof TierQuotas)[] = ["shelf", "volume", "book", "page"];

// Normalise ratios so they sum to 1
const rawTotal = keys.reduce((sum, k) => sum + ratios[k], 0);
const normalised = keys.map((k) => (rawTotal > 0 ? ratios[k] / rawTotal : 0.25));

// Compute proportional (floating) values, then floor
const proportional = normalised.map((r) => r * cap);
const floors = proportional.map(Math.floor);
let floorSum = floors.reduce((a, b) => a + b, 0);

// Distribute remainders via largest-remainder method
const remainders = proportional.map((p, i) => ({ idx: i, rem: p - floors[i] }));
remainders.sort((a, b) => b.rem - a.rem);
// Normalize ratios so they sum to 1
const rawTotal = tiers.reduce((sum, t) => sum + ratios[t], 0);
const normalized = tiers.map((t) => (rawTotal > 0 ? ratios[t] / rawTotal : 0.25));

let i = 0;
while (floorSum < cap) {
floors[remainders[i].idx] += 1;
floorSum += 1;
i += 1;
const cap = Math.max(0, Math.floor(capacity));
const idealShares = normalized.map((r) => r * cap);
const floors = idealShares.map((s) => Math.floor(s));
let remaining = cap - floors.reduce((a, b) => a + b, 0);

// Distribute remainders by largest fractional part
const remainders = idealShares.map((s, i) => ({
index: i,
remainder: s - floors[i],
}));
remainders.sort((a, b) => b.remainder - a.remainder);

for (const r of remainders) {
if (remaining <= 0) break;
floors[r.index]++;
remaining--;
}

return {
Expand All @@ -147,15 +143,16 @@ export function deriveTierQuotas(
}

// ---------------------------------------------------------------------------
// deriveCommunityQuotas — proportional with min(1) guarantee
// Community quota derivation
// ---------------------------------------------------------------------------

/**
* Distributes `tierBudget` slots proportionally among communities given their
* sizes, with a minimum of 1 slot per community (when budget allows).
* Distribute a tier budget proportionally across communities.
*
* Returns an empty array when `communitySizes` is empty.
* Uses largest-remainder method so quotas sum exactly to `tierBudget`.
* Each community receives a minimum of 1 slot when budget allows.
*
* Returns an empty array when `communitySizes` is empty.
* If `tierBudget` is 0, every community receives 0.
*/
export function deriveCommunityQuotas(
Expand All @@ -173,13 +170,13 @@ export function deriveCommunityQuotas(
// Phase 1: assign minimum 1 to each community if budget allows
const minPerCommunity = budget >= n ? 1 : 0;
const quotas = new Array<number>(n).fill(minPerCommunity);
const remaining = budget - minPerCommunity * n;
const remainingBudget = budget - minPerCommunity * n;

if (remaining === 0 || totalSize === 0) return quotas;
if (remainingBudget === 0 || totalSize === 0) return quotas;

// Phase 2: distribute remaining proportionally (largest-remainder)
const proportional = communitySizes.map(
(s) => (Math.max(0, s) / totalSize) * remaining,
(s) => (Math.max(0, s) / totalSize) * remainingBudget,
);
const floors = proportional.map(Math.floor);
let floorSum = floors.reduce((a, b) => a + b, 0);
Expand All @@ -188,7 +185,7 @@ export function deriveCommunityQuotas(
remainders.sort((a, b) => b.rem - a.rem);

let j = 0;
while (floorSum < remaining) {
while (floorSum < remainingBudget) {
floors[remainders[j].idx] += 1;
floorSum += 1;
j += 1;
Expand Down
Loading
Loading