feat(06-07): PURE projectTurnCards + makeStreetAt + the 21 turn-card copy keys (GREEN)

- cron.ts: linear progress projection (clamped), stateless 180 s cadence
  (due = nowSec % 180 < 60), dead-trip gate, classifyTurn-authority next-turn
  detection over consecutive-leg bearings, 4-line HTML caption anatomy
- D-12 street by DEFAULT: makeStreetAt hops snapToGraph → matched segment's
  nameId → nameAt (never the node index); snap miss → bare-verb degradation;
  matched-unnamed → cardStreetUnnamed
- strings.ts: cardAction* (8) + cardActionBare* (8) + cardStreetUnnamed +
  cardEtaTemplate + cardHonesty + cardOpenButton, en + ms verbatim
- GC1-GC8 + CS7 green: 116 passed, 0 failed; bot:typecheck exit 0
This commit is contained in:
araharah-executor
2026-10-24 17:07:58 +08:00
parent ed0e0d0c8e
commit f07021e271
3 changed files with 349 additions and 18 deletions
+261
View File
@@ -0,0 +1,261 @@
/*
* cron.ts — the minimized turn-card projection (06-07, GUID-05; D-11/D-12/D-13).
* ---------------------------------------------------------------------------
* PURE LAW: projectTurnCards and makeStreetAt are PURE — injected nowSec and
* graph, no KV, no R2, no Telegram, no clock reads — Node-testable. The
* scheduled() wiring lives in webhook.ts (the ExportedHandler's scheduled
* arm): it lists guidance:* keys, reads each envelope through the
* defensively-validated getGuidanceSession, and pushes due cards. ALL logging
* lives there too — this module has no console at all.
*
* D-12 (street by DEFAULT): the FROZEN envelope carries geometry only (GE2 —
* no steps field, and no envelope extension: a 5th property means drift), so
* the cron reconstructs the street itself. makeStreetAt snaps the CARD TURN's
* vertex to the memoized R2 graph — snapToGraph → the matched segment's
* nameId → graph.nameAt(nameId) — the SAME one-authority hop the app-side
* banner relies on. Edges carry names; nodes don't: the node index is NEVER
* fed to nameAt. DEGRADATION (D-03's designed-degradation pattern): the graph
* absent/corrupt in the cron isolate, or a snap miss → the resolver returns
* null → the card composes street-less with the bare verbs — honest absence,
* never a guessed name. A MATCHED but unnamed edge composes with
* cardStreetUnnamed ("UNNAMED ROAD") — still street-bearing, instrument voice.
*
* D-13 (honesty): the caption's strongest claim is "best-effort projection";
* the copy never promises live tracking (banned-word gate in the cron suite).
* CADENCE (D-11 discretion, RESEARCH Open Q 3): the crons config ticks every
* 60 s; a card is DUE on the first tick of each CARD_CADENCE_S window —
* stateless (zero cursor keys, zero KV writes; the 1,000-writes/day free
* budget holds — pinned structurally in guidance-cron.test.mjs).
*
* Card language: the envelope contract carries NO language field (GE2), so
* per pickLocale's fallback law the cards are EN; the ms table is kept in
* parity by the bot copy gate (CS7) for the day the contract grows a
* language surface.
*
* HTML-escape surface: ZERO. Every caption part is code-composed from
* numbers and string-table constants — no envelope-derived string
* (destinationName included) enters the caption; street text arrives only
* through makeStreetAt's graph lookup (the repo's own OSM-derived graph).
*/
import {
classifyTurn,
decodePolyline6,
deriveCumulativeDistanceM,
haversineM,
snapToGraph,
type GuidanceTurnKind,
type RoadGraph,
type RoutePoint,
} from "@araharah/routing/src/index.ts";
import type { GuidanceSessionEnvelope } from "../kv/guidance-sessions.ts";
import { strings } from "../i18n/strings.ts";
/** The chat card's cadence (D-11/D-13): one card window every 3 minutes;
* the window's first tick is the due tick. */
export const CARD_CADENCE_S = 180;
/** The Worker's cron scan tick — wrangler's crons entry ticks every 60 s. */
const SCAN_TICK_S = 60;
/** Segments shorter than this are GPS/codec noise — no bearing is derived from them. */
const DEGENERATE_SEGMENT_M = 1;
/** v1 is KL-metro only and Malaysia has no DST — the card's ETA clock is KL
* wall time (the envelope carries no driver timezone; the Worker's clock is
* UTC). Named constant, replay-tunable. */
const KL_UTC_OFFSET_S = 8 * 60 * 60;
/** The turn-card's glyph vocabulary — cron.ts's local union mirroring the 8
* committed PNG names (web/public/glyphs/<kind>.png); structurally the
* RouteStep.kind union (one authority with engine A's classifyTurn). */
export type GlyphKindName = GuidanceTurnKind;
/** One projection outcome: due + the card to push, or all-null. */
export interface TurnCard {
due: boolean;
glyph: GlyphKindName | null;
caption: string | null;
maneuverIdx: number | null;
}
function noCard(): TurnCard {
return { due: false, glyph: null, caption: null, maneuverIdx: null };
}
/** The instrument register prefix — GUIDE // T-{nn} is code-composed (UI-SPEC). */
const GUIDE_REGISTER = "GUIDE // T-";
/** kind → the strings key carrying that maneuver's street-bearing verb. */
const CARD_ACTION_KEYS: Record<GlyphKindName, keyof typeof strings.en> = {
depart: "cardActionDepart",
straight: "cardActionStraight",
left: "cardActionLeft",
right: "cardActionRight",
"slight-left": "cardActionSlightLeft",
"slight-right": "cardActionSlightRight",
uturn: "cardActionUturn",
arrive: "cardActionArrive",
};
/** kind → the strings key carrying that maneuver's street-less (bare) verb. */
const CARD_ACTION_BARE_KEYS: Record<GlyphKindName, keyof typeof strings.en> = {
depart: "cardActionBareDepart",
straight: "cardActionBareStraight",
left: "cardActionBareLeft",
right: "cardActionBareRight",
"slight-left": "cardActionBareSlightLeft",
"slight-right": "cardActionBareSlightRight",
uturn: "cardActionBareUturn",
arrive: "cardActionBareArrive",
};
const DEG_TO_RAD = Math.PI / 180;
/** Initial compass bearing a → b, degrees clockwise (the astar.ts convention). */
function initialBearingDeg(a: RoutePoint, b: RoutePoint): number {
const lat1 = a.lat * DEG_TO_RAD;
const lat2 = b.lat * DEG_TO_RAD;
const dLng = (b.lng - a.lng) * DEG_TO_RAD;
const y = Math.sin(dLng) * Math.cos(lat2);
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
return (Math.atan2(y, x) / DEG_TO_RAD + 360) % 360;
}
/** A maneuver register entry: the carded kind, its geometry vertex (the
* streetAt argument) and its meters-along-the-line. */
interface TurnEntry {
kind: GlyphKindName;
vertexIdx: number;
alongM: number;
}
/**
* The maneuver register from geometry ALONE (the envelope carries no steps
* field): depart at the origin, one classified entry per bearing-change
* vertex (classifyTurn is the ONE turn authority — "straight" results are
* cardable CONTINUE-ON maneuvers), arrive at the drawn end.
*/
function buildTurnRegister(points: readonly RoutePoint[], cumulative: readonly number[]): TurnEntry[] {
const register: TurnEntry[] = [{ kind: "depart", vertexIdx: 0, alongM: 0 }];
let prevBearing: number | null = null;
let prevSegEnd = -1;
for (let i = 0; i < points.length - 1; i++) {
const segM = cumulative[i + 1]! - cumulative[i]!;
if (segM < DEGENERATE_SEGMENT_M) continue; // sub-m noise — no bearing, no maneuver
const bearing = initialBearingDeg(points[i]!, points[i + 1]!);
if (prevBearing !== null) {
register.push({
kind: classifyTurn(prevBearing, bearing),
vertexIdx: prevSegEnd,
alongM: cumulative[prevSegEnd]!,
});
}
prevBearing = bearing;
prevSegEnd = i + 1;
}
register.push({
kind: "arrive",
vertexIdx: points.length - 1,
alongM: cumulative[cumulative.length - 1]!,
});
return register;
}
/** "{n} M" / "{n.n} KM" — the app's instrument distance formats (UI-SPEC). */
function formatDistance(m: number): string {
const meters = Math.max(0, Math.round(m));
if (meters < 1000) return `${meters} M`;
return `${(meters / 1000).toFixed(1)} KM`;
}
/** The 24-hour HH:MM arrival clock, KL wall time (see KL_UTC_OFFSET_S). */
function etaClock(etaSec: number): string {
const at = new Date((etaSec + KL_UTC_OFFSET_S) * 1000);
const hh = String(at.getUTCHours()).padStart(2, "0");
const mm = String(at.getUTCMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
/**
* Project one guidance envelope at `nowSec` into its turn-card decision —
* PURE: no KV, no R2, no Telegram, no clock reads.
*
* - progress: linear along the snapshot (elapsed/duration → distanceM),
* clamped to [0, distanceM]
* - dead trip (progress ≥ distanceM) → due false, no card (D-13)
* - cadence: nowSec % CARD_CADENCE_S < SCAN_TICK_S (D-11, stateless)
* - next maneuver: the FIRST register entry whose along-distance ≥ progress
* ("straight" results included — CONTINUE ON is a real card); none → the
* "straight" fallback at register index 00 (geometry/distance drift)
* - maneuver line: street present → "{verb} {STREET}"; null/absent resolver
* → the bare verb (honest absence, D-03)
*/
export function projectTurnCards(
envelope: GuidanceSessionEnvelope,
nowSec: number,
streetAt?: (vertexIdx: number) => string | null,
): TurnCard {
const snap = envelope.route_snapshot;
let points: RoutePoint[];
try {
points = decodePolyline6(snap.geometry);
} catch {
return noCard(); // malformed geometry — the mint-side validation refuses it; honest skip
}
if (points.length < 2) return noCard();
const elapsedS = nowSec - snap.startedAtSec;
const progressM =
snap.durationS > 0
? Math.max(0, Math.min(snap.distanceM, (snap.distanceM * elapsedS) / snap.durationS))
: snap.distanceM;
if (progressM >= snap.distanceM) return noCard(); // the trip would have ended — never card a dead trip
const due = nowSec % CARD_CADENCE_S < SCAN_TICK_S;
if (!due) return noCard(); // a non-due tick composes nothing — the card IS the due decision
const cumulative = deriveCumulativeDistanceM(points);
const register = buildTurnRegister(points, cumulative);
const entry = register.find((e) => e.alongM >= progressM) ?? null;
const maneuverIdx = entry !== null ? register.indexOf(entry) : 0;
const glyph: GlyphKindName = entry !== null ? entry.kind : "straight";
const vertexIdx = entry !== null ? entry.vertexIdx : points.length - 1;
const loc = strings.en;
const street = streetAt ? streetAt(vertexIdx) : null;
const verb = street ? loc[CARD_ACTION_KEYS[glyph]] : loc[CARD_ACTION_BARE_KEYS[glyph]];
// The line's number: the carded turn's remaining meters — or, on the
// overshoot fallback, the trip's remaining meters (the only honest number).
const distToTurn = entry !== null ? entry.alongM - progressM : snap.distanceM - progressM;
const remainingM = snap.distanceM - progressM;
const remainingS = (snap.durationS * remainingM) / snap.distanceM;
const caption = [
`${GUIDE_REGISTER}${String(maneuverIdx).padStart(2, "0")}`,
`<b>${formatDistance(distToTurn)} — ${verb}${street ? ` ${street.toUpperCase()}` : ""}</b>`,
loc.cardEtaTemplate.replace("{time}", etaClock(nowSec + remainingS)).replace("{km}", (remainingM / 1000).toFixed(1)),
loc.cardHonesty,
].join("\n");
return { due, glyph, caption, maneuverIdx };
}
/**
* The graph→street resolver factory — PURE: graph + decoded points in,
* resolver out; snapToGraph + nameAt imported from @araharah/routing, zero
* I/O. The cron wiring builds ONE resolver per carded envelope (its own
* geometry) and calls it ONLY for the card turn's vertex — one snap per
* carded tick, never a walk of the whole line (the bounded-snapping law;
* the CPU tradeoff is a named ratification item in 06-08's checkpoint).
*/
export function makeStreetAt(
graph: RoadGraph,
points: readonly RoutePoint[],
): (vertexIdx: number) => string | null {
return (vertexIdx: number): string | null => {
const point = points[vertexIdx];
if (!point) return null;
const snapped = snapToGraph(graph, point);
if (snapped === null) return null; // snap miss — honest absence (D-03), never a guessed name
if (snapped.nameId === null) return strings.en.cardStreetUnnamed; // matched but unnamed — instrument fallback
return graph.nameAt(snapped.nameId) ?? null; // the matched segment's nameId — NEVER the node index
};
}
+70
View File
@@ -49,6 +49,36 @@ export interface BotStrings {
* both locales (legal attribution, not prose; same string as
* web/src/i18n/strings.ts). */
osmAttribution: string;
/* ————— GUID-05 turn-card copy (06-07; D-12/D-13, UI-SPEC verbatim) ————— */
/** The street-BEARING maneuver verbs (the DEFAULT card line "{dist} — {verb} {STREET}"):
* en carries ONTO, ms carries KE. */
cardActionDepart: string;
cardActionStraight: string;
cardActionLeft: string;
cardActionRight: string;
cardActionSlightLeft: string;
cardActionSlightRight: string;
cardActionUturn: string;
cardActionArrive: string;
/** The street-LESS degradation verbs (graph absent / snap miss — D-03's honest
* absence pattern): bare verbs, never a guessed name. */
cardActionBareDepart: string;
cardActionBareStraight: string;
cardActionBareLeft: string;
cardActionBareRight: string;
cardActionBareSlightLeft: string;
cardActionBareSlightRight: string;
cardActionBareUturn: string;
cardActionBareArrive: string;
/** nameAt's unnamed id → the instrument fallback — still street-bearing. */
cardStreetUnnamed: string;
/** The projected-ETA telemetry template ({time} → HH:MM, {km} → one-decimal km). */
cardEtaTemplate: string;
/** D-13: the strongest claim the mechanism supports — never promises live tracking. */
cardHonesty: string;
/** The inline button resuming a killed webview from its envelope (?g=<id>, GUID-06). */
cardOpenButton: string;
}
export type Locale = "en" | "ms";
@@ -71,6 +101,26 @@ export const strings: Record<Locale, BotStrings> = {
searchQueryTooLong: "That query is too long. Send just the place name.",
searchOpenButton: "OPEN IN ARAHARAH",
osmAttribution: "© OpenStreetMap contributors",
cardActionDepart: "DEPART",
cardActionStraight: "CONTINUE ON",
cardActionLeft: "TURN LEFT ONTO",
cardActionRight: "TURN RIGHT ONTO",
cardActionSlightLeft: "KEEP LEFT ONTO",
cardActionSlightRight: "KEEP RIGHT ONTO",
cardActionUturn: "MAKE A U-TURN ONTO",
cardActionArrive: "ARRIVE AT",
cardActionBareDepart: "DEPART",
cardActionBareStraight: "CONTINUE",
cardActionBareLeft: "TURN LEFT",
cardActionBareRight: "TURN RIGHT",
cardActionBareSlightLeft: "KEEP LEFT",
cardActionBareSlightRight: "KEEP RIGHT",
cardActionBareUturn: "MAKE A U-TURN",
cardActionBareArrive: "ARRIVE",
cardStreetUnnamed: "UNNAMED ROAD",
cardEtaTemplate: "ETA {time} · {km} KM LEFT",
cardHonesty: "Best-effort projection — updates while the app is minimized.",
cardOpenButton: "OPEN GUIDANCE",
},
ms: {
startIntro:
@@ -89,6 +139,26 @@ export const strings: Record<Locale, BotStrings> = {
searchQueryTooLong: "Pertanyaan itu terlalu panjang. Hantar nama tempat sahaja.",
searchOpenButton: "OPEN IN ARAHARAH",
osmAttribution: "© OpenStreetMap contributors",
cardActionDepart: "MULA",
cardActionStraight: "TERUS KE",
cardActionLeft: "BELOK KIRI KE",
cardActionRight: "BELOK KANAN KE",
cardActionSlightLeft: "SEDAKIT KIRI KE",
cardActionSlightRight: "SEDAKIT KANAN KE",
cardActionUturn: "BUAT PUSARAN KE",
cardActionArrive: "TIBA DI",
cardActionBareDepart: "MULA",
cardActionBareStraight: "TERUS",
cardActionBareLeft: "BELOK KIRI",
cardActionBareRight: "BELOK KANAN",
cardActionBareSlightLeft: "SEDAKIT KIRI",
cardActionBareSlightRight: "SEDAKIT KANAN",
cardActionBareUturn: "BUAT PUSARAN",
cardActionBareArrive: "TIBA",
cardStreetUnnamed: "JALAN TANPA NAMA",
cardEtaTemplate: "ETA {masa} · {km} KM LAGI",
cardHonesty: "Unjuran cuba terbaik — dikemas kini semasa aplikasi diminimumkan.",
cardOpenButton: "BUKA PANDUAN",
},
};