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
179 changes: 179 additions & 0 deletions src/lib/morton/coverage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Root coverage-MOC parsing (mortie spec v1.0 section 7.3): the `ranges`
* envelope a store/product root's coverage.moc declares -- inclusive
* [first, last] runs of same-order shard cells within one base cell,
* consecutive in base-4 digit-tail rank, endpoints as decimal STRINGS
* (packed words exceed 2**53; JSON numbers would be float-mangled).
*
* Readers are MOC-first, always: the manifest plus this envelope yield
* every leaf path arithmetically, and recursive enumeration is
* out-of-contract for the viewer (issue #1 phase-6 amendment) -- there is
* deliberately no LIST fallback here. The leaf bitmap tier (zstd) stays
* server-side; the browser consumes the box/ranges tiers only.
*
* Posture split, mirroring moczarr's parse_root_coverage vs ranges_words
* exactly: the tolerant (-> null) bucket gates on `spec` + `encoding` ONLY --
* a missing/unknown envelope is a regenerable-cache miss, so the caller reports
* no-coverage. Everything structural (the `order` shape, the `ranges` shape,
* endpoint validity) is LOUD: a well-formed envelope carrying a corrupt body
* must never silently read empty, because there is no LIST fallback to recover
* the true coverage. A numeric-string `order` (e.g. "6") reads like a JSON
* number, as moczarr's ranges_words does.
*/

import {
decimalBase,
decimalOrder,
decimalRank,
rankTail,
} from "@/lib/morton/decimal.ts";
import { leafPath } from "@/lib/morton/hive.ts";
import type { HiveManifest } from "@/lib/morton/manifest.ts";

/** Convention version of coverage envelopes. */
export const COVERAGE_SPEC = "morton-moc/1";
/** Root coverage object name at a store/product root. */
export const ROOT_COVERAGE_NAME = "coverage.moc";

export interface RootCoverage {
spec: string;
encoding: "ranges";
/** Common HEALPix order of every range endpoint. */
order: number;
/** Inclusive [first, last] decimal-string runs. */
ranges: [string, string][];
}

/**
* The envelope's common order as an integer. Mirrors moczarr's ranges_words,
* which reads a numeric-string `order` (e.g. "6") as readily as a JSON number;
* a missing, non-numeric, or non-integer order is malformed and loud -- a
* corrupt order inside a well-formed envelope must never silently drop
* coverage.
*/
function coverageOrder(value: unknown): number {
if (typeof value === "number" && Number.isInteger(value)) {
return value;
}
if (typeof value === "string" && /^\d+$/.test(value)) {
return Number(value);
}
throw new Error(
`coverage order must be an integer (got ${JSON.stringify(value)})`
);
}

/**
* A usable store-root coverage envelope, or null. Tolerant ONLY at the
* envelope gate (moczarr parse_root_coverage): a non-object payload, an
* unknown `spec`, or a non-"ranges" `encoding` reads as absent -- the root MOC
* is a regenerable cache. A well-formed envelope with a malformed `order` or a
* non-list `ranges` is LOUD (moczarr ranges_words): a corrupt body must never
* read as no-coverage. Per-range structural and endpoint checks stay loud at
* expansion (checkRange).
*/
export function parseRootCoverage(payload: unknown): RootCoverage | null {
if (
typeof payload !== "object" ||
payload === null ||
Array.isArray(payload)
) {
return null;
}
const raw = payload as Record<string, unknown>;
if (raw["spec"] !== COVERAGE_SPEC || raw["encoding"] !== "ranges") {
return null;
}
const order = coverageOrder(raw["order"]);
const ranges = raw["ranges"];
if (!Array.isArray(ranges)) {
throw new Error(
`coverage ranges must be a list of [first, last] pairs ` +
`(got ${JSON.stringify(ranges)})`
);
}
return {
spec: COVERAGE_SPEC,
encoding: "ranges",
order,
ranges: ranges as [string, string][],
};
}

/** Validated (base, loRank, hiRank) of one range; throws when malformed. */
function checkRange(range: unknown, order: number): [string, number, number] {
if (!Array.isArray(range) || range.length !== 2) {
// A mangled cache must be loud, never a plausible partial answer.
throw new Error(
`coverage range must be a [first, last] pair (got ${JSON.stringify(range)})`
);
}
const [lo, hi] = range;
if (typeof lo !== "string" || typeof hi !== "string") {
// Spec section 7.3: endpoints are decimal strings, never JSON numbers.
throw new Error(
`coverage range endpoints must be decimal strings ` +
`(got [${JSON.stringify(lo)}, ${JSON.stringify(hi)}])`
);
}
const base = decimalBase(lo);
const loRank = decimalRank(lo);
const hiRank = decimalRank(hi);
const ok =
decimalBase(hi) === base &&
loRank <= hiRank &&
decimalOrder(lo) === order &&
decimalOrder(hi) === order;
if (!ok) {
throw new Error(
`malformed coverage range [${lo}, ${hi}] at order ${order}`
);
}
return [base, loRank, hiRank];
}

/**
* The covered shard ids, expanded exactly from the envelope's ranges --
* ascending within each range by construction (consecutive digit-tail
* rank). O(covered shards); containment checks should use rangesContain.
*/
export function rangesShardIds(envelope: RootCoverage): string[] {
const ids: string[] = [];
for (const range of envelope.ranges) {
const [base, loRank, hiRank] = checkRange(range, envelope.order);
for (let r = loRank; r <= hiRank; r++) {
ids.push(base + rankTail(r, envelope.order));
}
}
return [...new Set(ids)];
}

/** Whether the envelope's ranges list one shard id -- O(ranges). */
export function rangesContain(
envelope: RootCoverage,
shardId: string
): boolean {
if (decimalOrder(shardId) !== envelope.order) {
return false;
}
const base = decimalBase(shardId);
const rank = decimalRank(shardId);
return envelope.ranges.some((range) => {
const [rangeBase, loRank, hiRank] = checkRange(range, envelope.order);
return rangeBase === base && loRank <= rank && rank <= hiRank;
});
}

/**
* The store-relative leaf path of every covered shard -- the MOC-first
* arithmetic enumeration (manifest + root MOC, zero LISTs) the phase-6c
* data path consumes. `window` selects the windowed leaf under /2 and /3
* manifests.
*/
export function coveredLeafPaths(
manifest: HiveManifest,
envelope: RootCoverage,
window?: string | null
): string[] {
return rangesShardIds(envelope).map((id) => leafPath(manifest, id, window));
}
117 changes: 117 additions & 0 deletions src/lib/morton/decimal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Decimal morton string grammar (mortie spec v1.0 sections 2 and 4).
*
* Grammar: ["-"] base-digit *order-digit ["p"], base-digit 1..6, order
* digits 1..4, one per order. The sign renders the southern base cells
* (base cells 6..11); the string length past the sign+base component IS the
* order; a string prefix is a spatial ancestor. The terminal "p" kind
* suffix is legal only on a full order-29 string (points exist only at
* order 29) and is render/interchange-only -- paths never carry it. The
* section-4 tie-break: a p-marked string yields the POINT word, an unmarked
* order-29 string always yields the AREA word.
*/

import {
BODY_TUPLES,
MAX_ORDER,
areaSuffix,
baseCellOf,
isPointWord,
pointSuffix,
wordToNested,
} from "@/lib/morton/word.ts";

const DECIMAL_RE = /^(-?)([1-6])([1-4]*)(p?)$/;

const PREFIX_SHIFT = 60n;
const SUFFIX_BITS = 6n;

/** HEALPix order of a decimal id: one digit per level past the base. */
export function decimalOrder(id: string): number {
return id.length - (id.startsWith("-") ? 2 : 1);
}

/** The {sign+base} component of a decimal id ("-5" of "-5112333"). */
export function decimalBase(id: string): string {
return id.slice(0, id.startsWith("-") ? 2 : 1);
}

/**
* Base-4 value of a decimal id's digit tail (digits 1..4 -> 0..3): the
* rank convention of the coverage encodings -- ascending packed-word
* (Z-)order within one base cell at a fixed order. Number-valued, so only
* valid through order 24 tails (4**24 < 2**53) -- coverage endpoints live
* at shard orders, far below that.
*/
export function decimalRank(id: string): number {
let rank = 0;
for (const ch of id.slice(decimalBase(id).length)) {
rank = rank * 4 + (Number(ch) - 1);
}
return rank;
}

/** Inverse of decimalRank: the width-`depth` digit tail of a rank. */
export function rankTail(rank: number, depth: number): string {
const digits: string[] = [];
for (let i = 0; i < depth; i++) {
digits.push(String((rank % 4) + 1));
rank = Math.floor(rank / 4);
}
return digits.reverse().join("");
}

/**
* Parse a decimal morton string to its packed word (BigInt). Enforces the
* grammar, the order cap (29), and the p rules: "p" is legal solely on a
* full order-29 string and selects the POINT word; an unmarked order-29
* string parses as the AREA word (the section-4 tie-break).
*/
export function parseMortonDecimal(id: string): bigint {
const m = DECIMAL_RE.exec(id);
if (!m) {
throw new Error(`malformed decimal morton id ${JSON.stringify(id)}`);
}
const [, sign, baseDigit, digits, mark] = m;
const order = digits.length;
if (order > MAX_ORDER) {
throw new Error(`decimal morton id ${id} exceeds order ${MAX_ORDER}`);
}
if (mark === "p" && order !== MAX_ORDER) {
throw new Error(
`the "p" kind suffix is legal only at order ${MAX_ORDER} (got order ${order})`
);
}
// North bases 0..5 render 1..6; south bases 6..11 render -1..-6.
const baseCell = sign === "-" ? Number(baseDigit) + 5 : Number(baseDigit) - 1;
let word = BigInt(baseCell + 1) << PREFIX_SHIFT;
const bodyOrders = Math.min(order, BODY_TUPLES);
for (let n = 1; n <= bodyOrders; n++) {
const stored = BigInt(digits.charCodeAt(n - 1) - 0x31); // "1".."4" -> 0..3
word |= stored << (SUFFIX_BITS + 2n * BigInt(BODY_TUPLES - n));
}
const t28 = order >= 28 ? digits.charCodeAt(27) - 0x31 : 0;
const t29 = order >= 29 ? digits.charCodeAt(28) - 0x31 : 0;
const suffix =
mark === "p" ? pointSuffix(t28, t29) : areaSuffix(order, t28, t29);
return word | BigInt(suffix);
}

/**
* Render a packed word as its decimal string (decode-through-kernel). Area
* words render unmarked; POINT words carry the terminal "p", making the
* decimal round-trip lossless for both kinds. Throws on the empty sentinel
* or an invalid prefix.
*/
export function renderMortonDecimal(word: bigint): string {
const baseCell = baseCellOf(word);
const { order, nested } = wordToNested(word);
const within = nested - BigInt(baseCell) * (1n << (2n * BigInt(order)));
const southern = baseCell >= 6;
let s =
(southern ? "-" : "") + String(southern ? baseCell - 5 : baseCell + 1);
for (let n = order - 1; n >= 0; n--) {
s += String(Number((within >> (2n * BigInt(n))) & 3n) + 1);
}
return isPointWord(word) ? s + "p" : s;
}
Loading
Loading