From ee57bbbf43806c42133af14a5c073510c72736b4 Mon Sep 17 00:00:00 2001 From: Dana Fitkowski Date: Tue, 15 Sep 2026 11:35:16 -0400 Subject: [PATCH 1/2] Cross-calendar finish instants (v2.9.44): successors driven from the predecessor's finish instant on their own calendar; backward pass mirrored See the CHANGELOG entry for the P6 semantics, the measurements on a 2,898-activity five-calendar export, and the three updated expectations (V2942-7, RL-7) with their derivations. Release docs (README, DAUBERT, VERIFY_RELEASE evidence, tag, attestation) are not updated here. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 71 ++++++++++++ cpm-engine.js | 274 +++++++++++++++++++++++++++++++++++++++++---- cpm-engine.test.js | 185 +++++++++++++++++++++++++++--- package.json | 2 +- 4 files changed, 493 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d62cd2..460bc1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,77 @@ A stray bridge tag `temp-deploy-bridge-2026-05-11` (unrelated to any CHANGELOG e --- +## v2.9.44 — 2026-09-15 — cross-calendar finish instants: successors driven from the predecessor's finish instant on their own calendar; backward pass mirrored + +**Engine math changed.** The forward and backward passes both move on any +network whose relationships cross calendars, so a deliverable already issued +from a tagged build is inside the supersession window and needs the re-check +step in PROCEDURE.md. + +What P6 does: an early finish is an INSTANT, the close of the last working +period of the activity's own calendar (Friday 17:00 on Mon-Fri). A successor +starts at the first working instant of ITS OWN calendar at or after that +instant plus lag, the lag consumed as working time on the relationship-lag +calendar from the instant. + +What the engine did: it carried a finish as the opening of the next working +day on the FINISHING activity's calendar (Monday for a Friday finish), handed +that boundary to the successor as-is, walked the lag on the lag calendar from +it, and never snapped the result onto the successor's calendar. Measured on a +2,898-activity real export with five active calendars (a Mon-Fri design +calendar, a 680-exception blackout calendar, an installation calendar, a +seven-day calendar and the project calendar), once the 40 activities whose +stored dates the file's own logic cannot produce were pinned, every one of +the 25 residual root divergences from P6's stored early dates was one of: + +- a successor starting on a day its own calendar does not work (a + blackout-calendar successor inside its blackout, engine 2027-08-05 against + P6 2027-09-15, early finish equal because the duration walk skipped the + blackout anyway; a Mon-Fri successor of a seven-day predecessor on a Sunday); +- a seven-day successor of a Friday finish starting Monday, two days after + P6's Saturday (20 of the export's 109 seven-day activities); +- a successor whose calendar works the predecessor calendar's holiday + starting the day after it (Victoria Day and New Year's Day between the + design and installation calendars); +- a completed predecessor's successors starting on its finish day. + +Changes, paired in `cpm-engine.js` and the Python reference: + +- `ef_instant` / `ef_instant_date` on every node: the calendar day whose + opening the finish is (Saturday for a Friday 17:00 finish). `ef` / + `ef_date` stay the boundary on the activity's own calendar. +- FS / SS / FF / SF drives are computed as instants (`_lagFromInstant`) and + snapped onto the successor's calendar (`_snapFwd`); FF / SF anchors likewise. +- A positive lag is working time on the lag calendar counted FROM the + instant (Thursday 17:00 + one Mon-Fri day is Friday 17:00; Friday 17:00 + + one is Monday 17:00); a negative lag retreats from it. +- A finish milestone (`task_type` TT_FinMile) sits at the instant that drove + it; a start milestone at its own calendar's next working start; a + zero-duration node without a task type keeps the snapped instant. +- A completed predecessor's instant comes from the TIME of its actual finish + ('2027-03-05 17:00' is the close of Friday); a date-only value keeps the + legacy reading. The node keeps the date part for display. +- The data date is an instant too: 'YYYY-MM-DD 17:00' floors remaining work + on the next day. +- The backward pass mirrors it (`_lagBackFromInstant`, `_snapBwd`, + `_lfInstantOf`): a successor's late start is the instant the predecessor + must finish before, and the bound is expressed on the predecessor's own + calendar. Without this mirror the new forward instants manufactured + negative float on cross-calendar links (a Mon-Fri predecessor of a + seven-day successor was handed a Saturday late finish against its Monday + early-finish boundary, tf -2, no constraint anywhere); with it, forward + and backward walks are inverses again and the V2942-7 fixture that used + to manufacture tf -1 now reports the two seven-day days of float P6 does. + +Same-calendar networks are byte-identical to v2.9.43 (every crossval fixture +except the mixed-calendar F11 was unchanged; F11 now agrees between the +engines on the instant reading). Tests: XC-1..XC-10 in `cpm-engine.test.js` +(1,288 checks green), `test_cross_calendar_finish_instants_2026_09_15.py` on +the Python side (21 pins); crossval 153/153 and 86/86. Three existing +expectations that pinned the boundary walker's under-count were updated with +their derivation (V2942-7 successor-walk date and negative-float checks, RL-7 +Saturday start of a Mon-Fri activity). + ## v2.9.43 — 2026-09-02 — retained-logic P6 semantics: SS/SF drive from restart; restart snapped, always defined, not floored by a future actual start; corrupt-calendar P6 fallback (two-conjunct predicate) **Engine math changed.** The retained-logic forward pass, free-float slack diff --git a/cpm-engine.js b/cpm-engine.js index f1ed7a9..a2afd95 100644 --- a/cpm-engine.js +++ b/cpm-engine.js @@ -148,7 +148,7 @@ // Node.js crypto module for topology hash (E2). Null in browser; browser fallback uses FNV-1a. const _crypto = (typeof require !== 'undefined') ? (() => { try { return require('crypto'); } catch(e) { return null; } })() : null; -const ENGINE_VERSION = '2.9.43'; +const ENGINE_VERSION = '2.9.44'; // v2.9.20 A20-M5 — module-level DOS guards. The XER parser already enforces // these for raw-file ingest (see SECTION G). They're hoisted here so callers @@ -999,6 +999,109 @@ function _retreatWithAlerts(endNum, nDays, calendarInfo, alerts, ctx) { return subtractWorkDays(endNum, nDays, calendarInfo); } +// ---------------------------------------------------------------------------- +// v2.9.44 — finish INSTANTS for cross-calendar logic (paired with cpm.py). +// +// The engine carries an early finish as a BOUNDARY: the opening of the next +// working day on the FINISHING activity's own calendar (Monday for a Friday +// finish on Mon-Fri). P6 carries it as an INSTANT: the close of the last +// worked period (Friday 17:00). The two name the same moment only when the +// successor shares the calendar. A seven-day successor of that Friday finish +// starts on Saturday in P6; a successor whose calendar works the +// predecessor's holiday starts on the holiday; a successor with a blackout +// starts after the blackout. Measured on a 2,898-activity real export with +// five active calendars, every residual root divergence between the engine +// and P6's stored early dates was one of these shapes once the dates the +// file's own logic cannot produce were pinned. +// +// The day-number representation stays: an instant is the calendar day whose +// opening it is (Friday 17:00 = Saturday's opening = Saturday's day number). +// Nodes carry both: `ef` / `ef_date` remain the boundary on the activity's +// own calendar (what every existing consumer reads), `ef_instant` is what +// successors are driven from. +// ---------------------------------------------------------------------------- + +function _instantOf(dtStr) { + // Day-number instant of a P6 date-time string: 'YYYY-MM-DD' (or a morning + // time) is the opening of that day; a time at or after 12:00 is the close + // of that day, i.e. the opening of the next calendar day. + if (dtStr === null || dtStr === undefined) return 0; + const s = String(dtStr).trim(); + const n = dateToNum(s); + if (n <= 0) return n; + if (s.length >= 13 && (s[10] === ' ' || s[10] === 'T') && /^\d{2}$/.test(s.slice(11, 13))) { + if (parseInt(s.slice(11, 13), 10) >= 12) return n + 1; + } + return n; +} + +function _boundaryToInstant(boundaryNum, calendarInfo, alerts, ctx) { + // Instant (opening of the calendar day after the last worked day) for a + // working-day boundary on calendarInfo. No calendar: the boundary itself. + if (boundaryNum <= 0 || !calendarInfo) return boundaryNum; + return _retreatWithAlerts(boundaryNum, 1, calendarInfo, alerts, ctx) + 1; +} + +function _lagFromInstant(instant, lag, lagCal, alerts, ctx) { + // Consume `lag` working days on lagCal starting at `instant`; returns an + // instant. P6 semantics: a positive lag is working time counted from the + // predecessor's finish instant on the lag calendar (Friday 17:00 + 1 day + // on Mon-Fri is Monday 17:00), a negative lag is working time retreated + // from it (Friday 17:00 - 1 day is Friday 08:00), zero leaves the instant + // alone. Without a calendar the single ordinal-arithmetic call (and its + // ALERT) is the same one the pre-instant walk made. + if (instant <= 0 || !lagCal) { + return _advanceWithAlerts(instant, lag, lagCal, alerts, ctx); + } + const _lagRaw = Number(lag); + const n = _roundHalfUp(Number.isFinite(_lagRaw) ? _lagRaw : 0); + if (n === 0) return instant; + if (n > 0) { + const start = _advanceWithAlerts(instant, 0, lagCal, alerts, ctx); + const boundary = _advanceWithAlerts(start, lag, lagCal, alerts, ctx); + return _boundaryToInstant(boundary, lagCal, alerts, ctx); + } + return _advanceWithAlerts(instant, lag, lagCal, alerts, ctx); +} + +function _snapFwd(num, calendarInfo, alerts, ctx) { + // First working day of calendarInfo at or after num (no-op without a + // calendar, so the no-calendar path emits no extra ALERT). + if (num <= 0 || !calendarInfo) return num; + return _advanceWithAlerts(num, 0, calendarInfo, alerts, ctx); +} + +function _snapBwd(num, calendarInfo, alerts, ctx) { + // Last working day of calendarInfo at or before num (no-op without a + // calendar). + if (num <= 0 || !calendarInfo) return num; + return _retreatWithAlerts(num, 0, calendarInfo, alerts, ctx); +} + +function _lagBackFromInstant(instant, lag, lagCal, alerts, ctx) { + // Backward mirror of _lagFromInstant: the instant `lag` working days of + // lagCal BEFORE `instant` (a positive lag retreats, a negative lag - a + // lead - advances by the forward rule, zero leaves the instant alone). + // Without a calendar the single ordinal-arithmetic call (and its ALERT) + // is the same one the pre-instant walk made. + if (instant <= 0 || !lagCal) { + return _retreatWithAlerts(instant, lag, lagCal, alerts, ctx); + } + const _lagRaw = Number(lag); + const n = _roundHalfUp(Number.isFinite(_lagRaw) ? _lagRaw : 0); + if (n === 0) return instant; + if (n > 0) return _retreatWithAlerts(instant, lag, lagCal, alerts, ctx); + return _lagFromInstant(instant, -lag, lagCal, alerts, ctx); +} + +function _finishInstant(pnode) { + // The instant a successor is driven from: the node's finish instant when + // the forward pass (or a timed actual finish) stamped one, else its boundary. + const inst = pnode.ef_instant; + if (Number.isFinite(inst) && inst > 0) return inst; + return pnode.ef; +} + // ============================================================================ // SECTION B — Topological sort (Kahn's) + Tarjan SCC for cycle isolation // ============================================================================ @@ -1469,7 +1572,10 @@ function computeCPM(activities, relationships, opts) { // avoids rebuilding new Set(holidays). Caller's calMap is not mutated. const rawCalMap = opts.calMap || opts.cal_map || {}; const calMap = _preResolveCalendars(rawCalMap, alerts); - const ddNum = dataDate ? dateToNum(dataDate) : 0; + // v2.9.44 — the data date is an INSTANT too: 'YYYY-MM-DD 17:00' (the + // close of that day) floors remaining work on the NEXT day, exactly as P6 + // does; a date-only or morning value is the opening of that day, unchanged. + const ddNum = dataDate ? _instantOf(dataDate) : 0; // Build node map. // We track insertion order in a separate array because JavaScript's @@ -1665,6 +1771,18 @@ function computeCPM(activities, relationships, opts) { _alertOnSilentDateCoerce(actualFinish, 'actual_finish', code); let es = a.early_start ? dateToNum(a.early_start) : 0; let ef = a.early_finish ? dateToNum(a.early_finish) : 0; + // v2.9.44 — a completed predecessor drives its successors from its + // actual-finish INSTANT. The P6 string carries the time ('2027-03-05 + // 17:00' is the close of Friday, so a Mon-Fri successor starts Monday + // and a seven-day one Saturday); a date-only value has no instant and + // keeps the legacy reading, the finish day itself. The node keeps the + // date part for display, as before. + let efInstant = 0; + if (isComplete && actualFinish) { + efInstant = _instantOf(actualFinish); + } else if (isComplete) { + efInstant = ef; + } if (isComplete && actualFinish) { ef = dateToNum(actualFinish); // v2.9.13 F1-Bug3 — Forensic data-quality check: actual_finish @@ -1762,9 +1880,15 @@ function computeCPM(activities, relationships, opts) { tf: 0, is_complete: isComplete, is_fragnet: !!a.is_fragnet, - actual_start: actualStart, - actual_finish: actualFinish, + actual_start: String(actualStart).trim().slice(0, 10), + actual_finish: String(actualFinish).trim().slice(0, 10), clndr_id: a.clndr_id || '', + // v2.9.44 — finish instant (stamped by the forward pass for + // incomplete nodes) and the P6 task type, which decides whether a + // zero-duration node sits at its driving instant (TT_FinMile) or + // at its own calendar's next working start (everything else). + ef_instant: efInstant, + task_type: _tt, // v2.9.12 T1.6 — thread `alerts` + activity code so unrecognized // tokens / incomplete dates emit a forensically-visible WARN // instead of silently dropping. Backward-compat: callers that @@ -2136,6 +2260,9 @@ function computeCPM(activities, relationships, opts) { // duration rather than full duration (INFERRED — no P6 capture of // an FF/SF-into-in-progress combination exists yet; case 10 is FS). let _restartMaxDrive = 0; + // v2.9.44 — [snapped drive, instant] per relationship, so a finish + // milestone can sit at the instant that actually drove it. + const _driveInstants = []; // v2.9.12 F2.2 — FF/SF finish-anchor identity. Round-tripping // retreat→advance through duration drifts off the anchor whenever // the anchor lies on a non-workday under nodeCal. Capture the @@ -2152,34 +2279,53 @@ function computeCPM(activities, relationships, opts) { // (see lagCalFor); the DURATION walk stays on this activity's own // calendar, which is a different question. const lagCal = lagCalFor(pnode, node); + // v2.9.44 — every drive is computed as an INSTANT (the + // predecessor's finish or start instant, the lag consumed as + // working time on the lag calendar from that instant) and then + // snapped onto THIS activity's own calendar, which is where P6 + // puts an early start. Before this, the predecessor's boundary + // was handed over as-is and the lag walk snapped onto the LAG + // calendar, so a successor could start on a day its own calendar + // does not work, and a seven-day successor of a Friday finish + // started on Monday instead of Saturday. if (p.type === 'FS') { - drive = _advanceWithAlerts(pnode.ef, lag, lagCal, alerts, - 'FS lag ' + pnode.code + '->' + code); + const _ctx = 'FS lag ' + pnode.code + '->' + code; + const driveInstant = _lagFromInstant(_finishInstant(pnode), lag, lagCal, alerts, _ctx); + drive = _snapFwd(driveInstant, nodeCal, alerts, _ctx); + _driveInstants.push([drive, driveInstant]); } else if (p.type === 'SS') { // D1 — SS drives from the predecessor's remaining-start // reference (restart for a started incomplete pred; es // otherwise). See startDriveSrcFor. - drive = _advanceWithAlerts(startDriveSrcFor(pnode), lag, lagCal, alerts, - 'SS lag ' + pnode.code + '->' + code); + const _ctx = 'SS lag ' + pnode.code + '->' + code; + const driveInstant = _lagFromInstant(startDriveSrcFor(pnode), lag, lagCal, alerts, _ctx); + drive = _snapFwd(driveInstant, nodeCal, alerts, _ctx); + _driveInstants.push([drive, driveInstant]); } else if (p.type === 'FF') { - const ffAnchor = _advanceWithAlerts(pnode.ef, lag, lagCal, alerts, - 'FF lag ' + pnode.code + '->' + code); + const _ctx = 'FF lag ' + pnode.code + '->' + code; + const anchorInstant = _lagFromInstant(_finishInstant(pnode), lag, lagCal, alerts, _ctx); + const ffAnchor = _snapFwd(anchorInstant, nodeCal, alerts, _ctx); drive = _retreatWithAlerts(ffAnchor, node.duration_days, nodeCal, alerts, 'FF duration ' + code); thisAnchorEF = ffAnchor; + _driveInstants.push([ffAnchor, anchorInstant]); } else if (p.type === 'SF') { // D1 — SF anchors from the predecessor's remaining-start // reference, same as SS. INFERRED for SF specifically: the // corpus carries no discriminating SF instance; adopted by // symmetry with the measured SS rule. - const sfAnchor = _advanceWithAlerts(startDriveSrcFor(pnode), lag, lagCal, alerts, - 'SF lag ' + pnode.code + '->' + code); + const _ctx = 'SF lag ' + pnode.code + '->' + code; + const anchorInstant = _lagFromInstant(startDriveSrcFor(pnode), lag, lagCal, alerts, _ctx); + const sfAnchor = _snapFwd(anchorInstant, nodeCal, alerts, _ctx); drive = _retreatWithAlerts(sfAnchor, node.duration_days, nodeCal, alerts, 'SF duration ' + code); thisAnchorEF = sfAnchor; + _driveInstants.push([sfAnchor, anchorInstant]); } else { - drive = _advanceWithAlerts(pnode.ef, lag, lagCal, alerts, - 'FS-default lag ' + pnode.code + '->' + code); + const _ctx = 'FS-default lag ' + pnode.code + '->' + code; + const driveInstant = _lagFromInstant(_finishInstant(pnode), lag, lagCal, alerts, _ctx); + drive = _snapFwd(driveInstant, nodeCal, alerts, _ctx); + _driveInstants.push([drive, driveInstant]); } // v2.9.5 — when this node has an actual_start, predecessor logic // cannot push ES later. We still track the driving_predecessor for @@ -2622,6 +2768,36 @@ function computeCPM(activities, relationships, opts) { } } + // v2.9.44 — the finish INSTANT successors are driven from. A bar + // (remaining bar for started work) closes at the end of its last + // worked day: the instant is the opening of the following calendar + // day, whatever this activity's calendar says about that day. A + // zero-duration node sits at its own snapped day, except a finish + // milestone (TT_FinMile), which P6 places AT the instant that drove + // it (Friday 17:00 when its predecessor finished Friday), so a + // seven-day successor of the milestone starts Saturday; when nothing + // but the data date drove it, that instant is the data date itself. + const _barDays = (hasActualStart && !node.is_complete && _hasRem) + ? _remRaw : node.duration_days; + if (_barDays > 0) { + node.ef_instant = _boundaryToInstant(node.ef, nodeCal, alerts, + 'finish instant ' + code); + } else { + node.ef_instant = node.ef; + if (node.task_type === 'TT_FinMile' && !hasActualStart && node.ef === node.es) { + let _best = null; + for (const _di of _driveInstants) { + if (_di[0] === node.ef && (_best === null || _di[1] > _best)) _best = _di[1]; + } + if (_best !== null) { + node.ef_instant = _best; + } else if (ddNum > 0 && node.ef === _snapFwd(ddNum, nodeCal, alerts, + 'finish instant ' + code)) { + node.ef_instant = ddNum; + } + } + } + // v2.9.15 P2 (F14-4) — DATA_DATE-driven driver. When no pred and no // constraint won, but maxES === ddNum AND the activity has predecessors // (i.e. ddNum genuinely floored ES past where the preds would have put @@ -2738,6 +2914,26 @@ function computeCPM(activities, relationships, opts) { 'seed-LF ' + n.code); } + // v2.9.44 — a successor's late-finish INSTANT: the close of its last + // late-worked day (the boundary retreated to that day, plus one calendar + // day), or the late finish itself for a zero-duration node. Mirrors the + // forward ef_instant so FF / SF backward bounds retreat from the same + // kind of instant the forward pass advanced from. + function _lfInstantOf(snode) { + const _sr = snode.remaining_duration; + let _sBar; + if (snode.actual_start && !snode.is_complete && Number.isFinite(_sr) && _sr >= 0) { + _sBar = _sr; + } else { + _sBar = snode.duration_days; + } + if (_sBar > 0) { + return _boundaryToInstant(snode.lf, calFor(snode), alerts, + 'late finish instant ' + snode.code); + } + return snode.lf; + } + // Backward pass — initialize. for (let __i = 0; __i < _orderLen; __i++) { const n = nodes[_order[__i]]; @@ -2817,21 +3013,40 @@ function computeCPM(activities, relationships, opts) { let drive = null; let lsBound = null; const lag = s.lag_days; + // v2.9.44 — the backward walk mirrors the forward one in + // INSTANTS: the successor's late start is the instant it + // must not start after (its late finish instant is the close + // of its last late-worked day), the lag is retreated as + // working time on the lag calendar from that instant, and + // the result becomes THIS activity's bound on its own + // calendar - the boundary after its latest workable day for + // a finish bound (FS / FF), its latest workable day for a + // start bound (SS / SF). Without this mirror the forward + // instants left the two walks non-inverse: a Mon-Fri + // predecessor of a seven-day successor was handed a Saturday + // late finish against its Monday early-finish boundary and + // reported two days of negative float in a network with no + // constraint. if (s.type === 'FS') { - drive = _retreatWithAlerts(snode.ls, lag, sCal, alerts, - 'backward FS lag ' + code + '->' + snode.code); + const _ctx = 'backward FS lag ' + code + '->' + snode.code; + const _inst = _lagBackFromInstant(snode.ls, lag, sCal, alerts, _ctx); + drive = _snapFwd(_inst, nodeCal, alerts, _ctx); } else if (s.type === 'SS') { - lsBound = _retreatWithAlerts(snode.ls, lag, sCal, alerts, - 'backward SS lag ' + code + '->' + snode.code); + const _ctx = 'backward SS lag ' + code + '->' + snode.code; + const _inst = _lagBackFromInstant(snode.ls, lag, sCal, alerts, _ctx); + lsBound = _snapBwd(_inst, nodeCal, alerts, _ctx); } else if (s.type === 'FF') { - drive = _retreatWithAlerts(snode.lf, lag, sCal, alerts, - 'backward FF lag ' + code + '->' + snode.code); + const _ctx = 'backward FF lag ' + code + '->' + snode.code; + const _inst = _lagBackFromInstant(_lfInstantOf(snode), lag, sCal, alerts, _ctx); + drive = _snapFwd(_inst, nodeCal, alerts, _ctx); } else if (s.type === 'SF') { - lsBound = _retreatWithAlerts(snode.lf, lag, sCal, alerts, - 'backward SF lag ' + code + '->' + snode.code); + const _ctx = 'backward SF lag ' + code + '->' + snode.code; + const _inst = _lagBackFromInstant(_lfInstantOf(snode), lag, sCal, alerts, _ctx); + lsBound = _snapBwd(_inst, nodeCal, alerts, _ctx); } else { - drive = _retreatWithAlerts(snode.ls, lag, sCal, alerts, - 'backward default ' + code + '->' + snode.code); + const _ctx = 'backward default ' + code + '->' + snode.code; + const _inst = _lagBackFromInstant(snode.ls, lag, sCal, alerts, _ctx); + drive = _snapFwd(_inst, nodeCal, alerts, _ctx); } if (drive !== null && (minLF === null || drive < minLF)) minLF = drive; if (lsBound !== null && (_minLSBound === null || lsBound < _minLSBound)) { @@ -3059,6 +3274,13 @@ function computeCPM(activities, relationships, opts) { n.es = n.ls; n.ef = n.lf; n.tf = 0; + // v2.9.44 — the finish instant slides with the finish. + const _alapCal = n.clndr_id ? calMap[n.clndr_id] : null; + if (n.ef > n.es) { + n.ef_instant = _boundaryToInstant(n.ef, _alapCal, alerts, 'finish instant ' + c); + } else { + n.ef_instant = n.ef; + } // v2.9.23 — audit LOW R9. The ALAP slide shifts THIS activity's // ES/EF forward without re-running the forward pass through its // successors. If A is ALAP and A→B (FS+0), B.ES was set when A.EF @@ -3097,6 +3319,10 @@ function computeCPM(activities, relationships, opts) { const n = nodes[c]; n.es_date = numToDate(n.es); n.ef_date = numToDate(n.ef); + // v2.9.44 — the finish instant beside the boundary: the calendar day + // whose opening the finish is (Saturday for a Friday 17:00 finish). + if (!(Number.isFinite(n.ef_instant) && n.ef_instant > 0)) n.ef_instant = n.ef; + n.ef_instant_date = numToDate(n.ef_instant); // B4 — for in-progress activities the DISPLAY late start is the // recorded actual start; the remaining-work // late calculus is exposed separately, mirroring P6's grid diff --git a/cpm-engine.test.js b/cpm-engine.test.js index 86e9f8f..0434e6f 100644 --- a/cpm-engine.test.js +++ b/cpm-engine.test.js @@ -9612,24 +9612,32 @@ const _SEVEN = { workDays: [0, 1, 2, 3, 4, 5, 6], holidays: [] }; const succ = E.computeCPM(acts, rels, base); const pred = E.computeCPM(acts, rels, Object.assign({}, base, { relationshipLagCalendar: 'rcal_Predecessor' })); + // v2.9.44 (finish instants): P finishes Friday 01-09 16:00. Sixteen hours + // of the SUCCESSOR calendar's working time from that instant is Monday + // and Tuesday, so S starts Wednesday 01-14 (the boundary walker used to + // land it on Tuesday, having consumed only Monday). The predecessor walk + // consumes Saturday and Sunday and lands Monday 01-12 as before. check('V2942-7: the lag calendar is selectable and actually changes the walk', - succ.nodes.S.es_date === '2026-01-13' && pred.nodes.S.es_date === '2026-01-12', + succ.nodes.S.es_date === '2026-01-14' && pred.nodes.S.es_date === '2026-01-12', 'successor ' + succ.nodes.S.es_date + ' / predecessor ' + pred.nodes.S.es_date); check('V2942-7: the manifest declares which lag calendar produced the dates', succ.manifest.relationship_lag_calendar === 'successor' && pred.manifest.relationship_lag_calendar === 'predecessor', succ.manifest.relationship_lag_calendar + ' / ' + pred.manifest.relationship_lag_calendar); - // The same network manufactures negative float under the successor walk: - // P gets lf 2026-01-09 against ef 2026-01-10, tf -1, in a network with NO - // constraint and NO imposed finish — arithmetically impossible, so an - // engine artifact. Pre-fix it was published silently. - check('V2942-7: impossible negative float is detected and enumerated', - succ.nodes.P.tf < 0 && - succ.alerts.some(a => a.context === 'impossible-negative-float' && - a.message.indexOf('P (tf -1') !== -1), + // Before v2.9.44 the same network manufactured negative float under the + // successor walk (P lf 2026-01-09 against ef 2026-01-10, tf -1, with no + // constraint anywhere) because the forward lag walk under-counted at the + // non-working anchor while the backward walk did not. With drives taken + // from P's finish INSTANT the two walks are inverses again: S cannot + // start before Wednesday, so P has the two seven-day days (Saturday and + // Sunday) of float P6 also reports, and the impossible-negative-float + // guard has nothing to enumerate. The guard itself is unchanged. + check('V2942-7: the successor walk no longer manufactures negative float (P keeps its weekend float)', + succ.nodes.P.tf === 2 && + !succ.alerts.some(a => a.context === 'impossible-negative-float'), 'P.tf=' + succ.nodes.P.tf + ' alerts=' + JSON.stringify(succ.alerts.map(a => a.context))); - check('V2942-7: the predecessor walk removes the manufactured negative float', + check('V2942-7: the predecessor walk carries no manufactured negative float either', pred.nodes.P.tf === 0 && !pred.alerts.some(a => a.context === 'impossible-negative-float'), 'P.tf=' + pred.nodes.P.tf); @@ -9643,8 +9651,8 @@ const _SEVEN = { workDays: [0, 1, 2, 3, 4, 5, 6], holidays: [] }; !constrained.alerts.some(a => a.context === 'impossible-negative-float')); let threw = null; try { E.computeCPMForensicStrict(acts, rels, base); } catch (e) { threw = e; } - check('V2942-7: impossible negative float is fatal in forensic strict mode', - threw !== null && threw.context === 'impossible-negative-float', + check('V2942-7: forensic strict mode has no impossible negative float left to refuse here', + threw === null, threw ? threw.context : 'no throw'); } @@ -10308,9 +10316,27 @@ const _RL_MF = { MF: { work_days: [1, 2, 3, 4, 5], holidays: [] } }; const rPred = E.computeCPM(acts, rels, { dataDate: '2026-06-22', calMap: cal, relationshipLagCalendar: 'rcal_Predecessor' }); - check('RL-7: rcal_Predecessor — SS lag walks pred 7-day calendar from restart (2026-06-27)', - rPred.nodes.X.es_date === '2026-06-27', + // v2.9.44 (finish instants): the five-day walk on P's seven-day calendar + // from the Monday 06-22 restart still lands on Saturday 06-27, but X is + // on a Mon-Fri calendar and cannot start on a Saturday: its early start + // is Monday 06-29, the first working day of ITS calendar at or after + // the instant. (The value pinned here before, 06-27, put a Mon-Fri + // activity's start on a day it does not work.) + check('RL-7: rcal_Predecessor — SS lag walks pred 7-day calendar from restart, X snaps to its own Monday (2026-06-29)', + rPred.nodes.X.es_date === '2026-06-29', 'X.es=' + rPred.nodes.X.es_date); + // With a seven-day lag the two calendars still separate: seven seven-day + // days from Monday 06-22 is Monday 06-29 on P's calendar, seven Mon-Fri + // days is Wednesday 07-01 on X's. + const rels7 = [{ from_code: 'P', to_code: 'X', type: 'SS', lag_days: 7 }]; + const rPred7 = E.computeCPM(acts, rels7, + { dataDate: '2026-06-22', calMap: cal, + relationshipLagCalendar: 'rcal_Predecessor' }); + const rSucc7 = E.computeCPM(acts, rels7, + { dataDate: '2026-06-22', calMap: cal }); + check('RL-7: a seven-day SS lag still discriminates the lag calendar (pred 06-29 / succ 07-01)', + rPred7.nodes.X.es_date === '2026-06-29' && rSucc7.nodes.X.es_date === '2026-07-01', + 'pred ' + rPred7.nodes.X.es_date + ' / succ ' + rSucc7.nodes.X.es_date); const rSucc = E.computeCPM(acts, rels, { dataDate: '2026-06-22', calMap: cal }); check('RL-7: default successor mode — same lag walks MF calendar (2026-06-29)', @@ -10675,6 +10701,137 @@ const _RL_FF_GENUINE = '(0||CalendarData()((0||DaysOfWeek()(' + H2 ? 'dwd=' + H2.duration_working_days : 'no hammock'); } +// =========================================================================== +// XC — v2.9.44 cross-calendar finish INSTANTS (paired with the Python pins in +// _cpp_common/tests/test_cross_calendar_finish_instants_2026_09_15.py). +// P6 hands a successor the predecessor's finish INSTANT (Friday 17:00), and +// the successor starts at the first working instant of ITS OWN calendar at or +// after instant + lag. The engine handed over the predecessor's boundary +// (Monday) and never snapped onto the successor's calendar. +// =========================================================================== +{ + const XC_MF = { work_days: [1, 2, 3, 4, 5], holidays: [], special_workdays: [] }; + const XC_D7 = { work_days: [0, 1, 2, 3, 4, 5, 6], holidays: [], special_workdays: [] }; + const xcCal = { MF: XC_MF, D7: XC_D7 }; + const xcRun = (acts, rels, dd, extra) => E.computeCPM(acts, rels, Object.assign( + { dataDate: dd || '2027-03-01', calMap: xcCal, + relationshipLagCalendar: 'rcal_Predecessor' }, extra || {})); + + // XC-1 — FS+0 from a Friday finish into a seven-day successor: Saturday. + let r = xcRun([{ code: 'A', duration_days: 5, clndr_id: 'MF' }, + { code: 'B', duration_days: 3, clndr_id: 'D7' }], + [{ from_code: 'A', to_code: 'B', type: 'FS', lag_days: 0 }]); + check('XC-1: A keeps its own-calendar boundary (2027-03-08) and exposes the instant (03-06)', + r.nodes.A.ef_date === '2027-03-08' && r.nodes.A.ef_instant_date === '2027-03-06', + r.nodes.A.ef_date + ' / ' + r.nodes.A.ef_instant_date); + check('XC-1: seven-day successor of a Friday finish starts Saturday 2027-03-06 (was Monday)', + r.nodes.B.es_date === '2027-03-06' && r.nodes.B.ef_date === '2027-03-09', + r.nodes.B.es_date + ' / ' + r.nodes.B.ef_date); + + // XC-2 — FS+0 from a Saturday finish (seven-day) into Mon-Fri: Monday, not Sunday. + r = xcRun([{ code: 'A', duration_days: 6, clndr_id: 'D7' }, + { code: 'B', duration_days: 2, clndr_id: 'MF' }], + [{ from_code: 'A', to_code: 'B', type: 'FS', lag_days: 0 }]); + check('XC-2: Mon-Fri successor of a Saturday finish starts Monday 2027-03-08 (was Sunday)', + r.nodes.B.es_date === '2027-03-08' && r.nodes.B.ef_date === '2027-03-10', + r.nodes.B.es_date + ' / ' + r.nodes.B.ef_date); + + // XC-3 — positive lag is working time on the lag calendar FROM the instant. + r = xcRun([{ code: 'A', duration_days: 4, clndr_id: 'MF' }, + { code: 'B', duration_days: 2, clndr_id: 'D7' }], + [{ from_code: 'A', to_code: 'B', type: 'FS', lag_days: 1 }]); + check('XC-3: Thursday 17:00 + one Mon-Fri day is Friday 17:00, seven-day successor starts Saturday 03-06', + r.nodes.B.es_date === '2027-03-06', r.nodes.B.es_date); + + // XC-4 — FF+0 across calendars: both finish Friday 17:00. + r = xcRun([{ code: 'A', duration_days: 5, clndr_id: 'MF' }, + { code: 'B', duration_days: 3, clndr_id: 'D7' }], + [{ from_code: 'A', to_code: 'B', type: 'FF', lag_days: 0 }]); + check('XC-4: seven-day FF successor ends Friday (boundary 03-06) and works Wed-Fri (es 03-03)', + r.nodes.B.ef_date === '2027-03-06' && r.nodes.B.es_date === '2027-03-03', + r.nodes.B.es_date + ' / ' + r.nodes.B.ef_date); + + // XC-5 — a finish milestone hands its successor the driving instant; a + // start milestone hands its own calendar's start. + const mkMs = (tt) => xcRun( + [{ code: 'A', duration_days: 5, clndr_id: 'MF' }, + { code: 'M', duration_days: 0, clndr_id: 'MF', task_type: tt }, + { code: 'B', duration_days: 2, clndr_id: 'D7' }], + [{ from_code: 'A', to_code: 'M', type: 'FS', lag_days: 0 }, + { from_code: 'M', to_code: 'B', type: 'FS', lag_days: 0 }]); + const fin = mkMs('TT_FinMile'), sta = mkMs('TT_Mile'); + check('XC-5: TT_FinMile sits at Friday 17:00 (instant 03-06, display 03-08); its seven-day successor starts Saturday', + fin.nodes.M.ef_date === '2027-03-08' && fin.nodes.M.ef_instant_date === '2027-03-06' && + fin.nodes.B.es_date === '2027-03-06', + fin.nodes.M.ef_instant_date + ' / ' + fin.nodes.B.es_date); + check('XC-5: TT_Mile starts Monday on its own calendar and so does its successor', + sta.nodes.M.ef_instant_date === '2027-03-08' && sta.nodes.B.es_date === '2027-03-08', + sta.nodes.M.ef_instant_date + ' / ' + sta.nodes.B.es_date); + + // XC-6 — the predecessor's holiday is a working day on the successor's calendar. + const XC_MFH = { work_days: [1, 2, 3, 4, 5], holidays: ['2027-05-24'], special_workdays: [] }; + r = E.computeCPM([{ code: 'A', duration_days: 5, clndr_id: 'MFH' }, + { code: 'B', duration_days: 2, clndr_id: 'MF' }], + [{ from_code: 'A', to_code: 'B', type: 'FS', lag_days: 0 }], + { dataDate: '2027-05-17', calMap: { MF: XC_MF, MFH: XC_MFH }, + relationshipLagCalendar: 'rcal_Predecessor' }); + check('XC-6: successor starts on the predecessor calendar holiday it does not observe (2027-05-24)', + r.nodes.A.ef_date === '2027-05-25' && r.nodes.B.es_date === '2027-05-24', + r.nodes.A.ef_date + ' / ' + r.nodes.B.es_date); + + // XC-7 — a completed predecessor's finish TIME sets its instant. + r = xcRun([{ code: 'A', duration_days: 5, clndr_id: 'MF', actual_start: '2027-03-01', + actual_finish: '2027-03-05 17:00', is_complete: true }, + { code: 'B', duration_days: 2, clndr_id: 'MF' }, + { code: 'C', duration_days: 2, clndr_id: 'D7' }], + [{ from_code: 'A', to_code: 'B', type: 'FS', lag_days: 0 }, + { from_code: 'A', to_code: 'C', type: 'FS', lag_days: 0 }]); + check('XC-7: completed at Friday 17:00 - Mon-Fri successor Monday, seven-day successor Saturday, date kept for display', + r.nodes.A.actual_finish === '2027-03-05' && r.nodes.B.es_date === '2027-03-08' && + r.nodes.C.es_date === '2027-03-06', + r.nodes.A.actual_finish + ' / ' + r.nodes.B.es_date + ' / ' + r.nodes.C.es_date); + r = xcRun([{ code: 'A', duration_days: 5, clndr_id: 'MF', actual_start: '2027-03-01', + actual_finish: '2027-03-05', is_complete: true }, + { code: 'B', duration_days: 2, clndr_id: 'MF' }], + [{ from_code: 'A', to_code: 'B', type: 'FS', lag_days: 0 }]); + check('XC-7: a date-only actual finish keeps the legacy reading (successor 2027-03-05)', + r.nodes.B.es_date === '2027-03-05', r.nodes.B.es_date); + + // XC-8 — the data date is an instant too. + r = xcRun([{ code: 'A', duration_days: 2, clndr_id: 'MF' }], [], '2026-08-28 17:00'); + const r8b = xcRun([{ code: 'A', duration_days: 2, clndr_id: 'MF' }], [], '2026-08-28 08:00'); + check('XC-8: a Friday 17:00 data date floors remaining work on Monday 08-31; 08:00 keeps Friday', + r.nodes.A.es_date === '2026-08-31' && r8b.nodes.A.es_date === '2026-08-28', + r.nodes.A.es_date + ' / ' + r8b.nodes.A.es_date); + + // XC-9 — the backward pass mirrors the instants: a driving chain across + // calendars has zero float and no manufactured negative float. + r = E.computeCPM([{ code: 'A', duration_days: 5, clndr_id: 'MF' }, + { code: 'B', duration_days: 5, clndr_id: 'D7' }, + { code: 'C', duration_days: 3, clndr_id: 'MF' }], + [{ from_code: 'A', to_code: 'B', type: 'FS', lag_days: 0 }, + { from_code: 'B', to_code: 'C', type: 'FS', lag_days: 0 }], + { dataDate: '2026-01-05', calMap: xcCal }); + check('XC-9: Mon-Fri -> seven-day -> Mon-Fri chain: B Sat 01-10..Thu 01-15, C 01-15..01-20', + r.nodes.B.es_date === '2026-01-10' && r.nodes.B.ef_date === '2026-01-15' && + r.nodes.C.es_date === '2026-01-15' && r.nodes.C.ef_date === '2026-01-20', + r.nodes.B.es_date + '..' + r.nodes.B.ef_date + ' / ' + r.nodes.C.es_date + '..' + r.nodes.C.ef_date); + check('XC-9: every activity on the chain has zero float (A lf 01-12, B lf 01-15)', + r.nodes.A.lf_date === '2026-01-12' && r.nodes.B.lf_date === '2026-01-15' && + r.nodes.A.tf === 0 && r.nodes.B.tf === 0 && r.nodes.C.tf === 0 && + !r.alerts.some(a => a.context === 'impossible-negative-float'), + JSON.stringify([r.nodes.A.tf, r.nodes.B.tf, r.nodes.C.tf])); + + // XC-10 — FF across calendars: zero float on both. + r = E.computeCPM([{ code: 'A', duration_days: 5, clndr_id: 'MF' }, + { code: 'B', duration_days: 3, clndr_id: 'D7' }], + [{ from_code: 'A', to_code: 'B', type: 'FF', lag_days: 0 }], + { dataDate: '2026-01-05', calMap: xcCal }); + check('XC-10: FF across calendars - A lf 01-12 equals its ef, both tf 0', + r.nodes.A.lf_date === '2026-01-12' && r.nodes.A.tf === 0 && r.nodes.B.tf === 0, + r.nodes.A.lf_date + ' tf ' + r.nodes.A.tf + '/' + r.nodes.B.tf); +} + console.log('\n========================================'); console.log(' ' + pass + ' passed, ' + fail + ' failed'); console.log('========================================\n'); diff --git a/package.json b/package.json index fc21cee..10d60c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cpp-cpm-engine", - "version": "2.9.43", + "version": "2.9.44", "description": "Open-source CPM engine — AACE-canonical, Daubert-disclosed, JS/Python parity across enumerated CPM comparison fields.", "main": "cpm-engine.js", "scripts": { From 9c1aed4c7df64e6a0cd3a6d4c37f506f03fa4ba5 Mon Sep 17 00:00:00 2001 From: Dana Fitkowski Date: Tue, 15 Sep 2026 22:20:25 -0400 Subject: [PATCH 2/2] v2.9.44 release: Python reference regenerated in lockstep, coverage re-measured, P6 matrix recomputed, docs rotated python_reference/cpm.py is the canonical engine's v2.9.44 bytes put through the same derivation as before (the strip of the surfaces the harness does not import), so the public 46-fixture crossval reads 1009 of 1015 comparisons executed and bit-identical with the same 6 mutual skips. Its README pin, provenance note and expected-output transcript rotate with it. Coverage was re-measured on these bytes rather than carried forward: 93.79% statements (9,718 / 10,361), 83.32% branches (2,084 / 2,501), 94.69% functions (125 / 132) on the 1,288-check unit suite; DAUBERT S2.1, its reproduction paragraph, the README badge anchor and the VERIFY_RELEASE manifest all say so. The 13-case P6 comparison matrix was recomputed under the new engine against the same 2026-08-11 capture and still reads 13 / 13; the per-case engine outputs, the outputs summary and the generated matrix are refreshed, the hand-written capture notes in each case README are kept with only their engine-version heading rotated. Every current-state version and count reference moves to v2.9.44 / 1,288 (README, DAUBERT, VERIFY_RELEASE, FORENSIC_USE_SOP, CONTRIBUTING, SECURITY, docs/api, docs/jurisdictions, ROADMAP_OPEN status note); historic mentions keep the phrasing the version gate recognises. The VERIFY_RELEASE manifest's commit row is populated in the release-evidence packet once the tag exists. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 +- CONTRIBUTING.md | 4 +- DAUBERT.md | 44 +-- FORENSIC_USE_SOP.md | 6 +- README.md | 20 +- ROADMAP_OPEN.md | 6 +- SECURITY.md | 2 +- VERIFY_RELEASE.md | 52 +-- docs/api.md | 4 +- docs/jurisdictions.md | 2 +- python_reference/README.md | 30 +- python_reference/cpm.py | 308 ++++++++++++++++-- validation/crossval-summary.json | 4 +- .../p6-comparison/cases/01-fs-chain/README.md | 2 +- .../cases/01-fs-chain/engine-output.json | 37 ++- .../cases/02-ss-with-lag/README.md | 2 +- .../cases/02-ss-with-lag/engine-output.json | 28 +- .../cases/03-ff-with-lag/README.md | 2 +- .../cases/03-ff-with-lag/engine-output.json | 28 +- .../cases/04-sf-edge-case/README.md | 2 +- .../cases/04-sf-edge-case/engine-output.json | 28 +- .../cases/05-negative-float/README.md | 2 +- .../05-negative-float/engine-output.json | 28 +- .../cases/06-multiple-calendars/README.md | 2 +- .../06-multiple-calendars/engine-output.json | 28 +- .../cases/07-ontario-holidays/README.md | 2 +- .../07-ontario-holidays/engine-output.json | 19 +- .../08-in-progress-retained-logic/README.md | 2 +- .../engine-output.json | 28 +- .../cases/09-completed-successor/README.md | 2 +- .../09-completed-successor/engine-output.json | 26 +- .../10-out-of-sequence-progress/README.md | 2 +- .../engine-output.json | 28 +- .../cases/11-mandatory-start-finish/README.md | 2 +- .../engine-output.json | 38 ++- .../cases/12-snet-fnlt/README.md | 2 +- .../cases/12-snet-fnlt/engine-output.json | 28 +- .../p6-comparison/cases/13-alap/README.md | 2 +- .../cases/13-alap/engine-output.json | 37 ++- validation/p6-comparison/comparison-matrix.md | 2 +- .../p6-comparison/engine-outputs-summary.json | 4 +- 41 files changed, 743 insertions(+), 157 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 460bc1f..5409849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,7 +78,10 @@ Same-calendar networks are byte-identical to v2.9.43 (every crossval fixture except the mixed-calendar F11 was unchanged; F11 now agrees between the engines on the instant reading). Tests: XC-1..XC-10 in `cpm-engine.test.js` (1,288 checks green), `test_cross_calendar_finish_instants_2026_09_15.py` on -the Python side (21 pins); crossval 153/153 and 86/86. Three existing +the Python side (21 pins); the in-suite smoke crossvals 153/153 and 86/86; the +public harness 46 fixtures, 1009 of 1015 comparisons executed and bit-identical +(the 6 mutual skips unchanged); coverage re-measured on these bytes 2026-09-15: +93.79% statements, 83.32% branches, 94.69% functions. Three existing expectations that pinned the boundary walker's under-count were updated with their derivation (V2942-7 successor-walk date and negative-float checks, RL-7 Saturday start of a Mon-Fri activity). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5890bb1..85941c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thank you for considering a contribution. The engine is the core of an active fo ## Quick rules -1. **Every commit must pass 1,273 unit tests, 0 fail.** +1. **Every commit must pass 1,288 unit tests, 0 fail.** ```bash npm test @@ -96,7 +96,7 @@ Every output manifest reads `ENGINE_VERSION` and embeds it in the report. A vers ## Pull-request checklist -- [ ] `npm test` passes (1,273 tests, 0 fail). +- [ ] `npm test` passes (1,288 tests, 0 fail). - [ ] `npm run crossval` passes (1009 of 1015 defined comparisons executed and bit-identical, 0 failures, across 46 fixtures; 6 skipped rather than compared). - [ ] If you added a new citation, the URL is in `docs/citations.md` and the case/RP is real. - [ ] If you bumped the engine version, both `cpm-engine.js` and `package.json` are updated. diff --git a/DAUBERT.md b/DAUBERT.md index bb7f86d..53cfd61 100644 --- a/DAUBERT.md +++ b/DAUBERT.md @@ -1,4 +1,4 @@ -# Daubert / FRE 702 Disclosure — `cpm-engine` v2.9.43 +# Daubert / FRE 702 Disclosure — `cpm-engine` v2.9.44 This is a formal disclosure for the engine itself, modeled on the structured output of `buildDaubertDisclosure()`. It is intended for use as a starting point in expert-witness exhibits and FRCP 26(a)(2)(B) reports under the *Daubert v. Merrell Dow Pharmaceuticals* (1993) framework as codified in Federal Rule of Evidence 702 (Dec 1, 2023 amendment), and is forward-compatible with proposed FRE 707. @@ -34,27 +34,27 @@ The engine's correctness is exercised on six surfaces. Five are tabulated below; | Unit tests | `cpm-engine.test.js` — date helpers, calendar arithmetic, topo sort, Tarjan SCC, forward/backward pass, salvage mode, all strategy modes (TFM/LPM/MFP with divergence), kinematic delay dynamics, topology hash, Daubert disclosure, Bayesian update, multi-jurisdiction default holiday rule sets (66 jurisdiction codes, framework-aligned per [`docs/jurisdictions.md`](docs/jurisdictions.md); analysts must verify currency against the operative statute for forensic use), P6 primary + secondary constraints, TT_Hammock two-pass with full SS/FF/SF semantics, FF/SF relationship coverage, ALAP backward-pass tightening, Section D MC-constraint enforcement, hammock visited-set memoization, dateToNum 2-digit guard, Round 6 strong-assertion strengthening, Round 7 full hammock SS/FF/SF semantics, Round 8 R8A engine math fix wave, Round 9 v2.9.12 engine math fix wave, v2.9.22 audit HIGH wave (10 items: rel-trim, strict parse, typed input, getHolidays year-clamp, etc.), v2.9.23 small-batch wave (16 items: dedup, codepoint cmp, target_drtn WARN, etc.), v2.9.26 provenance + citation polish, v2.9.27 paired JS+Python fixes (R6 completed-succ skip, R12 data_date snap, R21 Python MonFri fast path, R9 tf_working_days backport, F24 ff/ff_working_days backport, A12 hash hardenings, R10 project_calendar fallback, R6 MS_Start widen WARN). | **1,273 / 1,273 passing** | | Cross-validation suite | `cpm-engine.crossval.js` — 46 fixtures, 1009 of 1015 checks executed, JS engine vs Python `compute_cpm` reference. The 6 unexecuted checks are confined to `ff_signed` (3) and `ff_signed_working_days` (3) on completed activities, where NEITHER engine emits the field; the harness skips those rather than failing them, so its `1009 / 1009` tally counts executed checks only. ES/EF/LS/LF, TF, `tf_working_days`, `ff` and `ff_working_days` were each compared on all 107 activity groups with no skips. Parity asserted on the enumerated comparison surface: forward/backward pass dates (ES/EF/LS/LF), Kahn topo order, critical-path codes, project finish, TF, TF working days, FF, FF working days, and signed FF, over fixtures that exercise FS/SS/FF/SF relationship types and lags; alert counts and severity are compared on 44 of the 46 fixtures. Three exclusions are disclosed rather than hidden. (1) Tarjan SCC is not part of this surface: `_tarjan_scc` was stripped from `python_reference/cpm.py` for OSS distribution (stated in that file's own header), and the Python reference detects cycles through `_topo_sort`'s `has_cycle` flag raising `ValueError`, so the two cycle fixtures (F23, F31) assert only that both engines refused and never compare an SCC decomposition. Tarjan SCC is covered by the JS unit suite instead (`cpm-engine.test.js` Section B plus the 5,000-node iterative-Tarjan overflow test), which is where the unit-test row above already claims it. (2) Alert parity is not compared on F23 and F31, where both engines throw and there is no output to compare. A former three-fixture carve-out (F20, F21, F27, where the out-of-sequence ALERT was believed JS-only) was retired on 2026-08-19: the Python reference has emitted that alert since the v2.9.27 paired-fix wave, and those six alert comparisons now execute and pass. (3) Neither engine emits `ff_signed` or `ff_signed_working_days` for completed activities, so 6 of the 1015 enumerated comparisons are never executed (3 on `ff_signed` and 3 on `ff_signed_working_days`, on the completed activities in F10, F20 and F43; in every one of the 6 both engines are silent, so none is a one-sided gap). The former one-sided gap — 58 comparisons where JS emitted a value and `python_reference/cpm.py` emitted none, because the reference did not assign `ff_signed_working_days` on the has-successors path — closed when that branch was ported. The harness therefore executes, and passes, 1009 of the 1015 enumerated comparisons. v2.9.27 expanded crossval coverage by backporting tf_working_days, ff, and ff_working_days fields to Python (previously documented JS-only gaps in F24). Bayesian and kinematic surfaces are JS-only and **explicitly excluded** from the bit-identical claim — see §8. | **1009 of 1015 enumerated comparisons executed and bit-identical; 6 skipped rather than failed (all 6 on completed activities where neither engine emits a signed-FF value)** | | Real-XER stress test | 282-activity real Primavera P6 export, JS vs Python (single non-public reference XER; kept locally, not committed — not independently reproducible from this repo) | **0 / 282 mismatches** | -| Branch + statement coverage | `c8` instrumentation over `cpm-engine.js` exercised by the unit suite (1,216 tests at the 2026-08-27 baseline capture). Reported as statement / branch / function / line coverage; see §2.1 for the coverage baseline, its measurement provenance, and the disclosed uncovered-line cluster. | **93.43% stmts / 83.12% branches / 93.96% funcs / 93.43% lines** | +| Branch + statement coverage | `c8` instrumentation over `cpm-engine.js` exercised by the unit suite (1,288 tests at the 2026-09-15 baseline capture). Reported as statement / branch / function / line coverage; see §2.1 for the coverage baseline, its measurement provenance, and the disclosed uncovered-line cluster. | **93.79% stmts / 83.32% branches / 94.69% funcs / 93.79% lines** | | Public-API surfaces | Kinematic delay dynamics (velocity / acceleration / jerk; pre-publication, JS-only), topology fingerprint hash (canonicalized topology under hashed-field set; not a forensic-equivalence statement — see §6), Daubert / FRE 702 disclosure wrapper, Bayesian update with hierarchical pooling (pre-publication, JS-only). | All exposed via public API + tests | Performance benchmarks (Node 18, M1 Mac): -- 1,273 unit tests +- 1,288 unit tests - 5,000-node linear-chain Tarjan SCC in **~8 ms** - 25,000-activity MonFri schedule (CPM run) in **~1.6 s** (after v2.1 optimizations) -### §2.1 Test Coverage (v2.9.40 baseline) +### §2.1 Test Coverage (v2.9.44 baseline) -Coverage is measured via [`c8`](https://github.com/bcoe/c8) over `cpm-engine.js` exercised by the unit suite, captured via `npm run coverage`; the baseline below was captured 2026-08-27 over the then-1,216-test suite. Reported numbers are not editorial — they are emitted by the test runner. +Coverage is measured via [`c8`](https://github.com/bcoe/c8) over `cpm-engine.js` exercised by the unit suite, captured via `npm run coverage`; the baseline below was captured 2026-09-15 over the 1,288-test suite, on the v2.9.44 bytes. Reported numbers are not editorial — they are emitted by the test runner. | Coverage Surface | Count | Pct | |---|---|---| -| Statements | 9,084 / 9,722 | **93.43%** | -| Branches | 1,961 / 2,359 | **83.12%** | -| Functions | 109 / 116 | **93.96%** | -| Lines | 9,084 / 9,722 | **93.43%** | +| Statements | 9,718 / 10,361 | **93.79%** | +| Branches | 2,084 / 2,501 | **83.32%** | +| Functions | 125 / 132 | **94.69%** | +| Lines | 9,718 / 10,361 | **93.79%** | -**Disclosed uncovered clusters.** A defensible coverage disclosure must name what is not covered, not just what is. The regions below are named in prose; the line-level list is generated rather than editorial. `npm run coverage` writes `coverage/cpm-engine.js.html`, which marks every uncovered statement in the bytes you are reading: 638 uncovered statements. Cite that HTML report rather than the console summary, whose `Uncovered Line #s` column is width-truncated. The uncovered statement clusters fall in these regions: +**Disclosed uncovered clusters.** A defensible coverage disclosure must name what is not covered, not just what is. The regions below are named in prose; the line-level list is generated rather than editorial. `npm run coverage` writes `coverage/cpm-engine.js.html`, which marks every uncovered statement in the bytes you are reading: 643 uncovered statements. Cite that HTML report rather than the console summary, whose `Uncovered Line #s` column is width-truncated. The uncovered statement clusters fall in these regions: - Defensive guards in salvage-mode early-exit paths (rarely exercised in the canonical fixtures; expanded coverage on the v3.0 strict-mode roadmap) - Bayesian / kinematic public-API surfaces that are JS-only and excluded from the crossval surface per §8 (covered by unit tests but lower branch coverage on the marginal-CI math edge cases) @@ -72,7 +72,7 @@ npm install --no-save # only pulls c8 devDep; runtime is still zero-dep npm run coverage ``` -The output should match the coverage baseline in §2.1 within rounding: 93.43% statements (9,084 / 9,722), 83.12% branches (1,961 / 2,359), 93.96% functions (109 / 116), 93.43% lines, measured on the v2.9.42 bytes (2026-08-27). The v2.9.43 retained-logic wave moves engine math on top of those bytes and the baseline has not been re-measured for this release, so a fresh run on the v2.9.43 tag will drift from the baseline as the new code enters the denominator. Drift beyond that documents itself. +The output should match the coverage baseline in §2.1 within rounding: 93.79% statements (9,718 / 10,361), 83.32% branches (2,084 / 2,501), 94.69% functions (125 / 132), 93.79% lines, measured on the v2.9.44 bytes (2026-09-15). The v2.9.44 cross-calendar finish-instant wave moved engine math, and the baseline WAS re-measured on those bytes for this release; the prior baseline (93.43 / 83.12 / 93.96, measured 2026-08-27 on earlier bytes and carried unmeasured through the previous release) is superseded. Drift beyond rounding on the v2.9.44 tag documents itself. --- @@ -110,11 +110,11 @@ Anyone can verify a published attestation: # attestations/latest.json is gitignored, and a witness you generate locally with # `npm run verify` is unsigned — verifying it returns HTTP 404 (no attestation for # its digest). Verify the CI-signed witness that ships in the tree instead: -gh attestation verify release-evidence/v2.9.43/witness-v2.9.43.json --owner danafitkowski +gh attestation verify release-evidence/v2.9.44/witness-v2.9.44.json --owner danafitkowski # Equivalent public route — note the release asset is named `latest.json`, # not `attestations-latest.json`: -# gh release download v2.9.43 --repo danafitkowski/cpp-cpm-engine --pattern 'latest.json' +# gh release download v2.9.44 --repo danafitkowski/cpp-cpm-engine --pattern 'latest.json' # gh attestation verify latest.json --owner danafitkowski ``` @@ -144,11 +144,11 @@ The underlying CPM math (Kelley & Walker forward/backward pass) is one of the mo **Cross-validation reports 1009 executed comparisons with 0 deviations across 46 fixtures on the enumerated CPM comparison surface (forward/backward pass dates, Kahn topo order, critical-path codes, project finish, FF/SF working-day arithmetic, TF, TF working days, FF, FF working days, and signed FF where both engines emit it; alert counts and severity are compared on 44 of the 46 fixtures, skipped only on the 2 cycle fixtures that compare refusal only (the former three-fixture out-of-sequence carve-out was retired 2026-08-19, the Python reference having emitted that alert since v2.9.27); Tarjan SCC is JS-unit-tested only and is not on this surface, having been stripped from `python_reference/cpm.py`). The harness prints `1009 / 1009`, but that denominator is the executed count, not the comparison surface: the full enumerated surface is 1015 comparisons, and 6 of them are skipped by the harness field guards (3 on `ff_signed`, 3 on `ff_signed_working_days`) and are neither counted nor failed. None of those 6 is substantive: all are null-vs-undefined artifacts on completed activities where neither engine emits the field. The 58 substantive skips disclosed through v2.9.41 closed when the Python reference began assigning `ff_signed_working_days` on the has-successors path. So 0% observed deviation holds over the 1009 executed comparisons, and agreement over the full 1015-comparison surface is 1009 of 1015 by the harness's own equality semantics. Bayesian and kinematic surfaces are JS-only and excluded (see §8).** **Real-XER stress reports 282 / 282 = 0% deviation (single non-public reference XER; not committed, not independently reproducible).** -This is the engine's **observed** error rate on the disclosed validation suite as measured 2026-08-27 (46 fixtures / 1009 cross-validation checks). It is not a general error-rate claim; it is the rate at which the engine has matched its Python sibling reference and a 282-activity P6 reference under the test surface defined in §2. +This is the engine's **observed** error rate on the disclosed validation suite as measured 2026-09-15 (46 fixtures / 1009 cross-validation checks). It is not a general error-rate claim; it is the rate at which the engine has matched its Python sibling reference and a 282-activity P6 reference under the test surface defined in §2. Performance characteristics: -- 1,273 unit tests run on Node 18. +- 1,288 unit tests run on Node 18. - 5,000-node linear chain Tarjan SCC in **~8 ms**. - A 25,000-activity Mon-Fri schedule (full forward + backward pass) runs in **~1.6 s** after the v2.1-C1 / v2.1-C2 optimizations. @@ -184,7 +184,7 @@ Every `computeCPM` result carries a `manifest` block: ```js result.manifest = { - engine_version: '2.9.43', // Synchronized with package.json (bump per release) + engine_version: '2.9.44', // Synchronized with package.json (bump per release) method_id: 'computeCPM', // 'computeTIA', 'computeCPMSalvaging', etc. activity_count: 282, relationship_count: 421, @@ -242,7 +242,7 @@ The engine and the validation suite were developed by the same author (Dana Fitk **Opposing experts are encouraged** to: 1. Clone the repository. -2. Run `npm run test:all` to reproduce the full 11-gate chain: the 1,216 unit tests and the 1009-of-1015 cross-validation checks across 46 fixtures (2,225 counted checks), plus the citation, client-name, truncation, version-drift, SOP, reissue, crypto-signoff, P6-comparison and corpus-DAG regression gates. Or `npm run verify` for the attestation-witness flow, which invokes five of those gates and records each in the witness JSON. +2. Run `npm run test:all` to reproduce the full 11-gate chain: the 1,288 unit tests and the 1009-of-1015 cross-validation checks across 46 fixtures (2,297 counted checks), plus the citation, client-name, truncation, version-drift, SOP, reissue, crypto-signoff, P6-comparison and corpus-DAG regression gates. Or `npm run verify` for the attestation-witness flow, which invokes five of those gates and records each in the witness JSON. 3. Run the engine against their own P6 schedule export and compare `tf_working_days` / `ff_working_days` against P6's Total Float / Free Float columns. Those are working days on the activity's own calendar, which is what P6's float columns mean; the raw calendar-day `tf` / `ff` fields are not the comparison surface (see §8). Finish dates need the day-start versus day-end convention applied first: the engine reports a day-start instant, so an engine EF or LF of `D` corresponds to a P6 finish at the close of the previous **workday** on that activity's own calendar (engine EF `2026-01-12` Mon = P6 `09-Jan-26 17:00` Fri, not `11-Jan-26`). Start dates (ES / LS) and actual dates carry no shift and compare directly. See `validation/p6-comparison/apply-p6-capture.py` for the normalization actually used. 4. Inspect the source — it is intentionally readable and well-commented (about 9,000 lines including narrative comments; `wc -l cpm-engine.js` gives the exact count for the release you are reading). @@ -251,8 +251,8 @@ The engine and the validation suite were developed by the same author (Dana Fitk ## Disclosure format version `disclosure_format_version: 1.0` -`engine_version: 2.9.43` -`generated_at:` (will be filled in by `buildDaubertDisclosure()` at runtime; this static document was first written 2026-05-24. Its cross-validation figures were re-measured 2026-08-27 by running `npm run crossval` at HEAD: 46 fixtures, 1009 of 1015 enumerated comparisons executed, 6 skipped, 0 failures. That is a figure refresh against the working tree, not a new tagged release — the engine version is unchanged at v2.9.41. The last tagged refresh was v2.9.41 (released 2026-08-19), which retired the F20/F21/F27 alert-parity carve-outs after the 2026-08-16 external audit showed the out-of-sequence ALERT has been emitted by the Python reference since v2.9.27, growing executed cross-validation from 925 to 931 checks on a 995-comparison surface. v2.9.40 is the P6 23.12 alignment wave: a 13-case comparison matrix captured from Primavera P6 Professional 23.12 standalone on 2026-08-11 via an automated import plus single-F9 round trip, five divergence families (working-day float units, open-end late-date seeding, mandatory-finish semantics, retained logic on out-of-sequence progress, free-float conventions) corrected against that capture, and cross-validation grown from 43 fixtures / 747 checks to 45 fixtures / 925 checks. The matrix reads 13 / 13 PASS, and disclosure requires the qualifier: the first and only capture scored 6 / 13, the engine was then changed against that capture's answers, and no held-out P6 case exists, so the matrix is a calibration record rather than an independent hold-out test. v2.9.38 (2026-07-04) corrected the attestation SHA chain to pin the shipped engine bytes and rewrote §E to the fields the engine actually emits (`method_caveat` on `computeKinematicDelay`, `methodology` on `computeBayesianUpdate`), removing the `methodology_status` and `woet_classifier` surfaces the engine never carried. v2.9.33 fixed the fatal-tier audit findings v2.9.32 left open (VERIFY_RELEASE.md test-count contradictions, missing release-evidence packets, SHA-sidecar wording, attestation script not wiring the new gates) plus the medium-tier residuals (jurisdictions bottom guarantee section, "no silent wrong-answer paths" absolute language, dead-context test strengthening, structured override fields with backward compat, README competitor-table removal, machine-readable SOP-checklist binding). Prior milestones preserved: v2.9.33 audit-response wave + version-drift regression gate + computeCPMSalvaging strict-mode refusal; v2.9.31 Section Q Forensic Strict Mode public API + 33 strict-mode unit tests; v2.9.27 audit closeout + crossval 444→747; v2.9.12 Round 9 engine math fix wave; v2.9.11 Round 7 independent-verification infrastructure tag; v2.9.9 full hammock SS/FF/SF semantics; v2.9.10 Round 7-8 independent-verification stack (public CI, Sigstore attestation, one-command local reproduction).) +`engine_version: 2.9.44` +`generated_at:` (will be filled in by `buildDaubertDisclosure()` at runtime; this static document was first written 2026-05-24. Its cross-validation figures were re-measured 2026-09-15 by running `npm run crossval` at HEAD: 46 fixtures, 1009 of 1015 enumerated comparisons executed, 6 skipped, 0 failures. That is a figure refresh against the working tree, not a new tagged release — the engine version is unchanged at v2.9.41. The last tagged refresh was v2.9.41 (released 2026-08-19), which retired the F20/F21/F27 alert-parity carve-outs after the 2026-08-16 external audit showed the out-of-sequence ALERT has been emitted by the Python reference since v2.9.27, growing executed cross-validation from 925 to 931 checks on a 995-comparison surface. v2.9.40 is the P6 23.12 alignment wave: a 13-case comparison matrix captured from Primavera P6 Professional 23.12 standalone on 2026-08-11 via an automated import plus single-F9 round trip, five divergence families (working-day float units, open-end late-date seeding, mandatory-finish semantics, retained logic on out-of-sequence progress, free-float conventions) corrected against that capture, and cross-validation grown from 43 fixtures / 747 checks to 45 fixtures / 925 checks. The matrix reads 13 / 13 PASS, and disclosure requires the qualifier: the first and only capture scored 6 / 13, the engine was then changed against that capture's answers, and no held-out P6 case exists, so the matrix is a calibration record rather than an independent hold-out test. v2.9.38 (2026-07-04) corrected the attestation SHA chain to pin the shipped engine bytes and rewrote §E to the fields the engine actually emits (`method_caveat` on `computeKinematicDelay`, `methodology` on `computeBayesianUpdate`), removing the `methodology_status` and `woet_classifier` surfaces the engine never carried. v2.9.33 fixed the fatal-tier audit findings v2.9.32 left open (VERIFY_RELEASE.md test-count contradictions, missing release-evidence packets, SHA-sidecar wording, attestation script not wiring the new gates) plus the medium-tier residuals (jurisdictions bottom guarantee section, "no silent wrong-answer paths" absolute language, dead-context test strengthening, structured override fields with backward compat, README competitor-table removal, machine-readable SOP-checklist binding). Prior milestones preserved: v2.9.33 audit-response wave + version-drift regression gate + computeCPMSalvaging strict-mode refusal; v2.9.31 Section Q Forensic Strict Mode public API + 33 strict-mode unit tests; v2.9.27 audit closeout + crossval 444→747; v2.9.12 Round 9 engine math fix wave; v2.9.11 Round 7 independent-verification infrastructure tag; v2.9.9 full hammock SS/FF/SF semantics; v2.9.10 Round 7-8 independent-verification stack (public CI, Sigstore attestation, one-command local reproduction).) --- @@ -333,7 +333,7 @@ own calendar), which is what P6's float columns mean; the raw calendar-day **Semantics.** Forward-pass clamps emit `{severity:'WARN', context:'constraint-applied'}`; impossibility-of-satisfaction cases emit `{severity:'ALERT', context:'constraint-violated'}`. Hammock-cycle topology emits `{severity:'ALERT', context:'hammock-cycle'}`. Hammock negative-span emits `{severity:'ALERT', context:'hammock-negative-span'}`. No silent-wrong-answer paths — every constraint that affects ES/EF/LS/LF, and every hammock anomaly, appears in `result.alerts`. -**Disclosure.** Opposing experts can audit every constraint applied during a run by filtering `result.alerts` on the contexts above. Pair with `result.manifest.engine_version === '2.9.43'` to confirm the constraint module version. +**Disclosure.** Opposing experts can audit every constraint applied during a run by filtering `result.alerts` on the contexts above. Pair with `result.manifest.engine_version === '2.9.44'` to confirm the constraint module version. **v2.9.12 — Round 9 engine math fix wave.** The audit memo identified ~30 substantive math defects across constraint handling, calendar arithmetic, in-progress + actuals, and JS/Python parity. T1.1 added MS_Start hard-pin on backward LF clamp (was JS+Python silent gap). T1.2-T1.3 emit `constraint-noop` WARN and suppress ES-side constraint clamps when an `actual_start` is present (P6 forward-pass semantics: a recorded actual start governs ES; both engines). T1.4 added Section D actual_start pinning with one-time `actual-start-not-anchored` WARN when `projectStart` is missing. T1.5 surfaces TT_LOE/TT_WBS/completed/zero-remaining drops + dangling-relationship drops + non-finite-lag rejections as INFO/ALERT alerts. T1.6 emits `constraint-unrecognized` / `constraint-incomplete` WARN on unknown tokens / missing dates. T1.7 added `CS_MANSTART` / `CS_MANFINISH` aliases. T1.8-T1.10 added Section D SNLT/FNLT/MS_Start violated+applied alerts symmetric with Section C. T2.11 rewrote Free Float on the binding-link's calendar so coincident lag-walked-forward pairs produce 0 slack. T2.12 made `_countWorkDaysBetween` signed (preserves negative-float forensic signal). T2.13 removed the `Math.max(0, ...)` FF clamp. T2.14 added `dateToNum` rollover guard (Feb 30 → 0 instead of silent rewrite to Mar 2). T2.15 rejects non-finite `lag_hr_cnt` from parseXER. T2.16 emits `invalid-calendar-falling-back` WARN when work_days is empty/invalid. T2.17 updated SUB_DAY_LAG_ROUNDED message to disclose V8 Math.round direction-bias. T3.18 added `remaining_duration` for P6 retained-logic EF anchoring. T3.19 pins LS=ES on backward pass when actual_start is present (in-progress, both engines). T3.20 guards `EF >= ES` in Section C EF-side helpers. T3.21 enumerates every unstarted predecessor + catches premature-start OoS. T3.22 emits `hammock-orphan` ALERT when no anchors resolve. T3.23 adds `duration_working_days` to hammocks. T3.24 emits `unrecognized-task-type` WARN. T4.25-T4.26 backport R8A-1 (MISSING_ACTUAL_START ES derivation) and ALAP-secondary-slot guard to the Python reference, rotating the SHA-256 pin. T4.27 was already in place on the JS side from T1.3. @@ -477,7 +477,7 @@ The original `result.alerts` array is **not** mutated — every alert remains vi ### What strict mode does NOT do - It does not validate that the analyst's overrides are *correct*. It enforces that the analyst documented the override in writing. Whether the rationale is defensible is the analyst's burden under Daubert / FRE 702. -- It does not guarantee P6 equivalence on the strict-mode-passing path. The P6 comparison evidence that does exist ships at [`validation/p6-comparison/`](validation/p6-comparison/), where the matrix reads **13 / 13** on the in-scope cases (two further cases are excluded as out of scope and documented in [`validation/engine-limitations/`](validation/engine-limitations/)). Those cases run plain `computeCPM`, not strict mode, and the 13 / 13 is fitted rather than held out: the single native P6 capture first scored 6 / 13, the engine was then corrected against those same pinned P6 answers, and the matrix was regenerated. No post-fix independent capture exists yet. The fix sequence is recorded in [`release-evidence/v2.9.43/validation-summary.md`](release-evidence/v2.9.43/validation-summary.md). +- It does not guarantee P6 equivalence on the strict-mode-passing path. The P6 comparison evidence that does exist ships at [`validation/p6-comparison/`](validation/p6-comparison/), where the matrix reads **13 / 13** on the in-scope cases (two further cases are excluded as out of scope and documented in [`validation/engine-limitations/`](validation/engine-limitations/)). Those cases run plain `computeCPM`, not strict mode, and the 13 / 13 is fitted rather than held out: the single native P6 capture first scored 6 / 13, the engine was then corrected against those same pinned P6 answers, and the matrix was regenerated. No post-fix independent capture exists yet. The fix sequence is recorded in the v2.9.43 packet, in its [validation summary](release-evidence/v2.9.43/validation-summary.md). - It does not extend to the `computeCPMSalvaging` path. Salvage mode is the inverse posture (best-effort triage of corrupt input) and refuses strict mode by design. - It is not retroactive. If you ran `computeCPM` without `forensic_strict: true` and want to validate after the fact, re-run with the flag set. @@ -485,7 +485,7 @@ The original `result.alerts` array is **not** mutated — every alert remains vi Strict mode shipped with 33 dedicated unit tests in v2.9.31, covering: API surface (8 tests); clean input pass-through; convenience wrapper; throw on each fatal context family; override with valid rationale; override with empty / whitespace / non-string rationale (each throws); unrelated override key (ignored); runCPM strict-mode refusal; default-off behavior; truthy-not-true non-activation. See `cpm-engine.test.js` SECTION R-v2.9.31 (the section anchor in the test file preserves the release that introduced these tests). -Those 33 strict-mode tests are still part of the engine's unit-test suite in the current v2.9.43 release (1,273 total tests including strict-mode coverage). +Those 33 strict-mode tests are still part of the engine's unit-test suite in the current v2.9.44 release (1,288 total tests including strict-mode coverage). --- diff --git a/FORENSIC_USE_SOP.md b/FORENSIC_USE_SOP.md index fa3162a..2e1cd98 100644 --- a/FORENSIC_USE_SOP.md +++ b/FORENSIC_USE_SOP.md @@ -258,7 +258,7 @@ See [DAUBERT.md §9](DAUBERT.md#9-forensic-strict-mode-shipped-v2931) for the fu **Do:** - Verify the deliverable's report manifest references: - - Engine version (e.g., `cpm-engine v2.9.43`) + - Engine version (e.g., `cpm-engine v2.9.44`) - Source SHA-256 (from Step 2) - This SOP (`FORENSIC_USE_SOP.md`) - DAUBERT.md (engine disclosure) @@ -310,7 +310,7 @@ live work. Do not wait for a client to ask. FRE 702 attacks come in two flavors: -1. **Attacks on principles.** "The engine itself is unreliable, the math is wrong, the validation is insufficient." → The engine's [DAUBERT.md](DAUBERT.md) + [VERIFY_RELEASE.md](VERIFY_RELEASE.md) + the v2.9.43 verification chain answer this layer. +1. **Attacks on principles.** "The engine itself is unreliable, the math is wrong, the validation is insufficient." → The engine's [DAUBERT.md](DAUBERT.md) + [VERIFY_RELEASE.md](VERIFY_RELEASE.md) + the v2.9.44 verification chain answer this layer. 2. **Attacks on application.** "Even granting the engine's validation record, the analyst applied it incorrectly: missed an alert, used the wrong calendar, mislabeled the method, didn't document the overrides, didn't verify against P6 on a controlling activity." → This SOP answers that layer. @@ -360,4 +360,4 @@ only the second is an answer. ## Document version -This SOP is aligned to `cpm-engine` v2.9.43. SOP revisions are tracked in [`CHANGELOG.md`](CHANGELOG.md) under the engine version that introduced them. +This SOP is aligned to `cpm-engine` v2.9.44. SOP revisions are tracked in [`CHANGELOG.md`](CHANGELOG.md) under the engine version that introduced them. diff --git a/README.md b/README.md index ff87aaf..524e732 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,15 @@ [![npm version](https://img.shields.io/npm/v/cpp-cpm-engine.svg)](https://www.npmjs.com/package/cpp-cpm-engine) [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -[![tests: 1273 passing](https://img.shields.io/badge/tests-1273%20passing-brightgreen.svg)](cpm-engine.test.js) +[![tests: 1288 passing](https://img.shields.io/badge/tests-1288%20passing-brightgreen.svg)](cpm-engine.test.js) [![crossval: JS↔Python 1009/1015](https://img.shields.io/badge/JS%E2%86%94Python-1009%2F1015-brightgreen.svg)](DAUBERT.md#31-independent-verification) -[![coverage: 93%](https://img.shields.io/badge/coverage-93%25%20stmts%20%2F%2083%25%20branches-brightgreen.svg)](DAUBERT.md#21-test-coverage-v2940-baseline) +[![coverage: 93%](https://img.shields.io/badge/coverage-93%25%20stmts%20%2F%2083%25%20branches-brightgreen.svg)](DAUBERT.md#21-test-coverage-v2944-baseline) [![verify](https://github.com/danafitkowski/cpp-cpm-engine/actions/workflows/verify.yml/badge.svg)](https://github.com/danafitkowski/cpp-cpm-engine/actions/workflows/verify.yml) [![Daubert: disclosed](https://img.shields.io/badge/Daubert-disclosed-blueviolet.svg)](DAUBERT.md) [![AACE: 29R--03 / 49R--06 / 52R--06](https://img.shields.io/badge/AACE-29R--03%20%7C%2049R--06%20%7C%2052R--06-orange.svg)](docs/citations.md) An open-source CPM engine with AACE-aligned methodology and a published [Daubert disclosure](DAUBERT.md). -**Open source. AACE-aligned. Daubert-disclosed. 1,273 JS unit tests, plus JS/Python parity on 1009 of 1015 enumerated cross-validation comparisons; the remaining 6 are not compared because neither engine emits the field on a completed activity (see [DAUBERT.md §3.1](DAUBERT.md#31-independent-verification)).** +**Open source. AACE-aligned. Daubert-disclosed. 1,288 JS unit tests, plus JS/Python parity on 1009 of 1015 enumerated cross-validation comparisons; the remaining 6 are not compared because neither engine emits the field on a completed activity (see [DAUBERT.md §3.1](DAUBERT.md#31-independent-verification)).** Court-facing usage: pair this engine with the analyst-application discipline in [`FORENSIC_USE_SOP.md`](FORENSIC_USE_SOP.md), the verification chain in [`VERIFY_RELEASE.md`](VERIFY_RELEASE.md), the per-release receipts in [`release-evidence/`](release-evidence/), and the field-level P6 comparison framework in [`validation/p6-comparison/`](validation/p6-comparison/). Do NOT cite README in a court-facing report — cite the documents listed in [DAUBERT.md](DAUBERT.md) and [`FORENSIC_USE_SOP.md` §Required pairing](FORENSIC_USE_SOP.md). @@ -25,7 +25,7 @@ Maintained by [Critical Path Partners](https://criticalpathpartners.ca) — a fo ```bash git clone https://github.com/danafitkowski/cpp-cpm-engine.git cd cpp-cpm-engine -git checkout v2.9.43 +git checkout v2.9.44 ``` The engine has zero runtime dependencies, so a clone is all it needs. This is the same install path [`VERIFY_RELEASE.md`](VERIFY_RELEASE.md) gives an opposing expert. @@ -51,7 +51,7 @@ const result = E.computeCPM( console.log('Project finish:', result.projectFinish); // 2026-01-21 console.log('Critical path:', result.criticalCodesArray); // ['A', 'B', 'C'] -console.log('Engine version:', result.manifest.engine_version); // 2.9.43 +console.log('Engine version:', result.manifest.engine_version); // 2.9.44 ``` That's it. Forward pass, backward pass, total float, free float, calendar arithmetic, P6-conventional date math, multi-jurisdiction holidays — all done. @@ -111,7 +111,7 @@ Every computation emits a manifest: ```js result.manifest = { - engine_version: '2.9.43', + engine_version: '2.9.44', method_id: 'computeCPM', activity_count: 3, relationship_count: 2, @@ -142,7 +142,7 @@ The engine has a Python sibling (`_cpp_common/scripts/cpm.py`) used by every CPP ```bash npm run crossval -# 46 fixtures, 1009 checks executed, 0 deviations, measured 2026-09-02. +# 46 fixtures, 1009 checks executed, 0 deviations, measured 2026-09-15. # A further 6 comparisons on the enumerated surface (1015 total) are skipped rather than failed by the harness field guards, all on ff_signed / ff_signed_working_days on completed activities, where NEITHER engine emits the field. ``` @@ -196,7 +196,7 @@ The CPP forensic suite (forensic-delay-analysis, claims-preparation, claim-workb If you use this engine in academic work or expert-witness reports, please cite: -> Fitkowski, D. (2026). *cpm-engine: An open-source critical-path-method engine with AACE-canonical method labels and a published Daubert disclosure.* Critical Path Partners. Version 2.9.43. +> Fitkowski, D. (2026). *cpm-engine: An open-source critical-path-method engine with AACE-canonical method labels and a published Daubert disclosure.* Critical Path Partners. Version 2.9.44. Algorithm citations are in [`docs/citations.md`](docs/citations.md). All citations have been verified against primary sources. @@ -218,13 +218,13 @@ You can use this engine in commercial forensic consulting, in academic research, **v2.9.10 (2026-05-16) — Round 7-8 hardening.** Independent-verification infrastructure (public CI on 9 OS × Node combos, Sigstore-signed witness JSONs, one-command local reproduction via `npm run verify`) ships as a tagged release. Engine math byte-identical to v2.9.9; that is a docs + infra release. See [DAUBERT.md §3.1](DAUBERT.md#31-independent-verification) and the new [§10 Roadmap](DAUBERT.md#10-roadmap--forward-looking-daubert-hardening). -See [CHANGELOG.md](CHANGELOG.md) for the full release history through v2.9.43. +See [CHANGELOG.md](CHANGELOG.md) for the full release history through v2.9.44. --- ## Contributing -See [`CONTRIBUTING.md`](CONTRIBUTING.md). Forensic correctness is enforced — every commit must pass 1,273 unit tests and the cross-validation harness, in which 1009 of 1015 defined comparisons execute and are bit-identical with 0 failures across 46 fixtures (the remaining 6 are skipped rather than compared — 3 `ff_signed`, 3 `ff_signed_working_days`, all on completed activities where neither engine emits the field), plus the citation regression, truncation regression, and version-drift regression gates (all wired into `npm run test:all` and `npm run verify`). New citations require WebSearch-verified URLs. No fabricated case names. No LLM-generated narratives in core engine paths. +See [`CONTRIBUTING.md`](CONTRIBUTING.md). Forensic correctness is enforced — every commit must pass 1,288 unit tests and the cross-validation harness, in which 1009 of 1015 defined comparisons execute and are bit-identical with 0 failures across 46 fixtures (the remaining 6 are skipped rather than compared — 3 `ff_signed`, 3 `ff_signed_working_days`, all on completed activities where neither engine emits the field), plus the citation regression, truncation regression, and version-drift regression gates (all wired into `npm run test:all` and `npm run verify`). New citations require WebSearch-verified URLs. No fabricated case names. No LLM-generated narratives in core engine paths. --- diff --git a/ROADMAP_OPEN.md b/ROADMAP_OPEN.md index c4000d8..89e327b 100644 --- a/ROADMAP_OPEN.md +++ b/ROADMAP_OPEN.md @@ -80,6 +80,10 @@ No audit item changed status between v2.9.39 and v2.9.43; the two releases since - **Item 6 stays as recorded:** 13 / 13 is fitted to the one 2026-08-11 capture. The independent held-out capture (about one hour in P6) remains Dana's action and is the only thing that turns the matrix from fitted to blind. - **Item 8 closed 2026-09-08** as superseded by the private oracle harness; no public corpus is planned. +## Status at v2.9.44 (reviewed 2026-09-15) + +No audit item changed status. v2.9.44 is an engine-math release (cross-calendar finish instants, see CHANGELOG.md): the cross-validation surface is unchanged at 1,009 of 1,015 comparisons across 46 fixtures, the unit suite grew from 1,273 to 1,288, coverage was re-measured on the new bytes (93.79% statements / 83.32% branches / 94.69% functions), and the 13-case P6 comparison matrix was recomputed under the new engine against the same 2026-08-11 capture. Item 6 stands: that matrix is still fitted to one capture, and the held-out capture remains Dana's action. + ## Document version -Aligned to `cpm-engine` v2.9.43. Update on every release that closes or opens an audit item. +Aligned to `cpm-engine` v2.9.44. Update on every release that closes or opens an audit item. diff --git a/SECURITY.md b/SECURITY.md index 98ca6a8..ebc4c2a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -61,7 +61,7 @@ We follow a **90-day coordinated-disclosure timeline**: 1. **Day 0** — report received, reporter acknowledged within 72 hours. 2. **Day 0-7** — triage, severity assignment, fix plan. -3. **Day 7-60** — fix developed, tested against the full 1,273-unit / 1009-crossval suite, and against any new regression test the reporter supplies. +3. **Day 7-60** — fix developed, tested against the full 1,288-unit / 1009-crossval suite, and against any new regression test the reporter supplies. 4. **Day 60-90** — release coordinated with the reporter. Both sides agree a public-disclosure date. 5. **Day 90** — public disclosure (CHANGELOG entry, GitHub Security Advisory, optional CVE) regardless of whether all downstream consumers have upgraded. The engine is open-source; closed downstream consumers are responsible for their own patch windows. diff --git a/VERIFY_RELEASE.md b/VERIFY_RELEASE.md index 693b461..11f91ee 100644 --- a/VERIFY_RELEASE.md +++ b/VERIFY_RELEASE.md @@ -1,8 +1,8 @@ -# VERIFY_RELEASE.md — `cpm-engine` v2.9.43 Forensic Verification Packet +# VERIFY_RELEASE.md — `cpm-engine` v2.9.44 Forensic Verification Packet -This document is the **courtroom-exhibit-form** of the engine release verification chain. Cite **this file**, the **Sigstore-signed witness** attached to the [v2.9.43 release](https://github.com/danafitkowski/cpp-cpm-engine/releases/tag/v2.9.43), and the [DAUBERT.md](DAUBERT.md) disclosure together — that triad is the engine's reliability record under FRE 702 / Daubert Prong 1 (testability). +This document is the **courtroom-exhibit-form** of the engine release verification chain. Cite **this file**, the **Sigstore-signed witness** attached to the [v2.9.44 release](https://github.com/danafitkowski/cpp-cpm-engine/releases/tag/v2.9.44), and the [DAUBERT.md](DAUBERT.md) disclosure together — that triad is the engine's reliability record under FRE 702 / Daubert Prong 1 (testability). -> **Status:** v2.9.43, an engine-math release. Retained-logic remaining-work semantics move to P6's: an SS or SF drive from a started predecessor reads the predecessor's restart rather than its historical actual start, completed predecessors no longer drive a started successor's restart, the restart anchor snaps forward on the activity's own calendar, a future actual start no longer floors the restart, and `parseXER` detects corrupt calendar records and falls back to P6-Standard, gated on an illegal `clndr_type` token. A result computed on v2.9.42 or earlier can differ from a result computed here, so a deliverable already issued from an earlier tagged build is inside the supersession window and needs the re-check step in the operator procedure. See [CHANGELOG.md](CHANGELOG.md) for the measured corpus results behind each rule. +> **Status:** v2.9.44, an engine-math release. Cross-calendar logic moves to P6's finish-instant semantics: a successor is driven from its predecessor's finish instant (the close of the last worked period) and starts at the first working instant of its own calendar, a lag is working time on the relationship-lag calendar counted from that instant, a finish milestone sits at the instant that drove it, a completed predecessor's instant comes from the time of its actual finish, the data date honours its time of day, and the backward pass mirrors all of it. Same-calendar networks are unchanged. A result computed on v2.9.44 or earlier can differ on any relationship that crosses calendars, so a deliverable already issued from an earlier tagged build is inside the supersession window and needs the re-check step in the operator procedure. See [CHANGELOG.md](CHANGELOG.md) for the measurements on a real five-calendar export behind the change. ## What this file proves @@ -18,22 +18,22 @@ What it does **not** prove: that the engine produces correct CPM dates for every --- -## Release manifest — v2.9.43 +## Release manifest — v2.9.44 | Item | Value | |---|---| -| Tag | `v2.9.43` | -| Commit SHA | `0a8d52f7a54cdf0c796e54f27549eeda61950aa1` | -| Release date | 2026-09-02 | +| Tag | `v2.9.44` | +| Commit SHA | populated at tag time; the tagged commit is recorded in `release-evidence/v2.9.44/VERIFY_RELEASE.md` | +| Release date | 2026-09-15 | | Engine source | `cpm-engine.js` | | Engine SHA-256 | computed at attestation time; mirrored in the per-release `release-evidence/v/cpm-engine.js.sha256` (the top-level `cpm-engine.js.sha256` is **gitignored** per `scripts/attestation.js` — it is a per-machine regenerated artifact, not a committed pin). | | Python reference | `python_reference/cpm.py` | | Python reference SHA-256 | computed at attestation time; mirrored in the per-release `release-evidence/v/python_reference-cpm.py.sha256` (the top-level `python_reference/cpm.py.sha256` is **gitignored** for the same reason — generated artifact, not committed pin). | -| Witness JSON (release asset) | `attestations/latest.json` on [the v2.9.43 release page](https://github.com/danafitkowski/cpp-cpm-engine/releases/tag/v2.9.43) | -| Unit tests | 1,273 / 1,273 passing | +| Witness JSON (release asset) | `attestations/latest.json` on [the v2.9.44 release page](https://github.com/danafitkowski/cpp-cpm-engine/releases/tag/v2.9.44) | +| Unit tests | 1,288 / 1,288 passing | | Cross-validation | 1009 of 1015 enumerated comparisons bit-identical across 46 fixtures, 0 deviations. The harness prints `Checks: 1009 / 1009` because its denominator is the executed count: 6 comparisons on the enumerated surface (3 `ff_signed`, 3 `ff_signed_working_days`) are skipped by the field guards, all on completed activities where NEITHER engine emits the field, so none is an open port gap. The 58 one-sided skips that were open port gaps closed when the has-successors branch was ported (see [DAUBERT.md §3.1](DAUBERT.md#31-independent-verification)) | -| Branch coverage | 83.12% (1,961 / 2,359 branches), measured on the v2.9.42 bytes and not re-measured for this release; see [DAUBERT.md §2.1](DAUBERT.md#21-test-coverage-v2940-baseline) | -| Statement coverage | 93.43% (9,084 / 9,722 statements), same §2.1 baseline measurement | +| Branch coverage | 83.32% (2,084 / 2,501 branches), measured on the v2.9.44 bytes 2026-09-15; see [DAUBERT.md §2.1](DAUBERT.md#21-test-coverage-v2944-baseline) | +| Statement coverage | 93.79% (9,718 / 10,361 statements), same §2.1 baseline measurement | | Citation regression | PASS | | `npm run verify` verdict | PASS | @@ -49,7 +49,7 @@ This is the cheapest verification step. It does not require the verifier to trus # Clone the repository at the tagged commit git clone https://github.com/danafitkowski/cpp-cpm-engine cd cpp-cpm-engine -git checkout v2.9.43 +git checkout v2.9.44 # Compute the SHA-256 of the engine source shasum -a 256 cpm-engine.js @@ -76,7 +76,7 @@ This is the cryptographic integrity layer. The verifier confirms that the witnes # From the GitHub release page, download attestations/latest.json # (it is attached as a release asset, not committed to the repo tree; # the asset is named `latest.json`) -gh release download v2.9.43 \ +gh release download v2.9.44 \ --repo danafitkowski/cpp-cpm-engine \ --pattern "latest.json" ``` @@ -128,7 +128,7 @@ This is the strongest verification step. The verifier ignores all of the propone ```bash git clone https://github.com/danafitkowski/cpp-cpm-engine cd cpp-cpm-engine -git checkout v2.9.43 +git checkout v2.9.44 # Optional — install c8 devDep for coverage reporting npm install --no-save @@ -141,12 +141,12 @@ npm run verify ``` === cpm-engine verification === -package version: 2.9.43 +package version: 2.9.44 engine.sha256: python_ref.sha256: [1/3] unit tests - 1273 passed, 0 failed + 1288 passed, 0 failed [2/3] cross-validation Fixtures: 46 passed, 0 failed @@ -160,7 +160,7 @@ Verdict: PASS Witness written to: attestations/latest.json ``` -**What this proves.** The verifier's machine reproduces the same SHA-256 hashes, the same 1,273 / 1009 pass counts, and the same PASS verdict — without any code from the proponent running at verification time other than the source files the verifier just downloaded and hashed. +**What this proves.** The verifier's machine reproduces the same SHA-256 hashes, the same 1,288 / 1009 pass counts, and the same PASS verdict — without any code from the proponent running at verification time other than the source files the verifier just downloaded and hashed. **Drift documents itself.** Any mismatch — different SHA, different pass count, different verdict — is itself usable evidence. The verifier can publish a witness from their own machine showing the drift; it is the same JSON shape as the proponent's witness. @@ -172,7 +172,7 @@ Layers 1-3 verify the engine against itself. The next layer — outside the prop This packet does **not** yet include a third-party reproduction memo from an outside scheduler / programmer / academic. The single biggest credibility step beyond Layers 1-3 is a signed Layer 4 attestation; pursuit of that attestation is on the [DAUBERT.md §10 roadmap](DAUBERT.md#10-roadmap--forward-looking-daubert-hardening). -What an opposing expert can do **today** without waiting for that memo: clone v2.9.43, run `npm run verify`, run the engine against three or four of their own P6 schedule exports, compare outputs to P6 native values field-by-field, and either confirm or document the discrepancy. The engine's source is open and the verification surface is one command. +What an opposing expert can do **today** without waiting for that memo: clone v2.9.44, run `npm run verify`, run the engine against three or four of their own P6 schedule exports, compare outputs to P6 native values field-by-field, and either confirm or document the discrepancy. The engine's source is open and the verification surface is one command. --- @@ -189,26 +189,26 @@ What an opposing expert can do **today** without waiting for that memo: clone v2 ## How to cite this verification packet in an expert report ``` -Verification chain for cpm-engine v2.9.43: - Tag: v2.9.43 +Verification chain for cpm-engine v2.9.44: + Tag: v2.9.44 Commit SHA: Engine SHA-256: Python ref SHA-256: Witness: attestations/latest.json (Sigstore-signed via GitHub OIDC, recorded on Rekor transparency log) - Verification: `npm run verify` PASS, 1,273 / 1,273 unit tests, + Verification: `npm run verify` PASS, 1,288 / 1,288 unit tests, 1009 / 1009 crossval checks executed across 46 fixtures (1009 of a 1015-comparison enumerated surface; 6 skipped) - Coverage: 93.43% stmts / 83.12% branches / 93.96% funcs, - measured on the v2.9.42 bytes and not re-measured for - this release (see cpp-cpm-engine/DAUBERT.md §2.1) + Coverage: 93.79% stmts / 83.32% branches / 94.69% funcs, + measured on the v2.9.44 bytes 2026-09-15 + (see cpp-cpm-engine/DAUBERT.md §2.1) Disclosure: cpp-cpm-engine/DAUBERT.md Reproduction: `git clone github.com/danafitkowski/cpp-cpm-engine && \ - git checkout v2.9.43 && npm run verify` + git checkout v2.9.44 && npm run verify` ``` This packet is intended to be attached as an exhibit to an FRCP 26(a)(2)(B) report alongside DAUBERT.md. It is also referenced from the engine's own [Daubert disclosure surface](DAUBERT.md) §3.1 Layer 2. --- -*Document version: aligned to `cpm-engine` v2.9.43. SHA values populate at tag time from `cpm-engine.js.sha256` and `python_reference/cpm.py.sha256` in the release tree, and from the Sigstore-signed `attestations/latest.json` release asset.* +*Document version: aligned to `cpm-engine` v2.9.44. SHA values populate at tag time from `cpm-engine.js.sha256` and `python_reference/cpm.py.sha256` in the release tree, and from the Sigstore-signed `attestations/latest.json` release asset.* diff --git a/docs/api.md b/docs/api.md index 7593de4..4b460f4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,7 +12,7 @@ const E = require('@critical-path-partners/cpm-engine'); | Name | Type | Description | |------------------------|---------|--------------------------------------------------------------------------| -| `E.ENGINE_VERSION` | string | Engine version string. Synchronized with `package.json`. e.g. `'2.9.43'` at the current tag.| +| `E.ENGINE_VERSION` | string | Engine version string. Synchronized with `package.json`. e.g. `'2.9.44'` at the current tag.| | `E.EPOCH_YEAR` | number | `2020` — the epoch anchor for internal day-offset arithmetic. | | `E.EPOCH_MONTH` | number | `1`. | | `E.EPOCH_DAY` | number | `1`. | @@ -377,7 +377,7 @@ Build a structured Daubert / FRE 707 disclosure package. ```js const d = E.buildDaubertDisclosure(cpmResult, { activities, relationships, // For topology hash - test_count: 1273, + test_count: 1288, validator_independence: '...', method_caveat: '...', }); diff --git a/docs/jurisdictions.md b/docs/jurisdictions.md index 78f696c..eccb34d 100644 --- a/docs/jurisdictions.md +++ b/docs/jurisdictions.md @@ -158,4 +158,4 @@ For non-forensic CPM (planning, lookahead, monthly progress reporting), the defa --- -*Document version: aligned to `cpm-engine` v2.9.43. Framework citations verified at top-level (Canada Labour Code, 5 U.S.C. § 6103); provincial / state framework names are reference-level pointers and should be reconciled to current statute by the analyst per the [Forensic-use guidance](#forensic-use-guidance) above.* +*Document version: aligned to `cpm-engine` v2.9.44. Framework citations verified at top-level (Canada Labour Code, 5 U.S.C. § 6103); provincial / state framework names are reference-level pointers and should be reconciled to current statute by the analyst per the [Forensic-use guidance](#forensic-use-guidance) above.* diff --git a/python_reference/README.md b/python_reference/README.md index d114628..df214e2 100644 --- a/python_reference/README.md +++ b/python_reference/README.md @@ -36,12 +36,34 @@ have been applied: `date_to_num` — plus, as of v2.9.43, the D7 clndr_data decoder (`decode_clndr_data` / `decode_calendar_record`), the Python parity twin of the JS parseXER-side calendar decode. +3. v2.9.44 (cross-calendar finish instants) is applied here exactly as to + the canonical engine, by the same patch: successors are driven from the + predecessor's finish INSTANT and snapped onto their own calendar, lags + are working time on the lag calendar from that instant, and the backward + pass mirrors it. See CHANGELOG.md v2.9.44. ## SHA-256 Pin ``` -cpm.py SHA-256: 83c6db6f61b36d8c8c22fee59d37bfeb36d3d8bc131a3716793e930327301ed5 - +cpm.py SHA-256: a3ebb418739c36c4af6a9088a229cc81d3cae58d8f45efad8620e4d9b50799d1 + +(v2.9.44 cross-calendar finish instants 2026-09-15 - bumped from +83c6db6f...: a successor is driven from its predecessor's finish INSTANT +(the close of the last worked period, Friday 17:00 = the opening of +Saturday) and snapped onto its OWN calendar; a positive lag is working +time on the relationship-lag calendar counted from that instant; a finish +milestone (task_type TT_FinMile) sits at the instant that drove it; a +completed predecessor's instant comes from the time of its actual finish; +the data date honours its time of day; the backward pass mirrors it +(_lag_back_from_instant / _snap_bwd / _lf_instant_of), so forward and +backward walks are inverses on cross-calendar links. Nodes carry +ef_instant / ef_instant_date beside the boundary ef. Applied to this +reference by the same patch as the canonical engine; the 46-fixture +harness stays at 1009 of 1015 executed and bit-identical. Measured on a +2,898-activity five-calendar real export: every residual root divergence +from P6's stored early dates was one of these shapes once the 40 rows +whose stored dates the file's own logic cannot produce were pinned. +Prior: (v2.9.43 retained-logic P6 semantics wave 2026-09-02 - bumped from 76cff495... (and re-rotated within the unpushed wave from 7249f3ed... by the F4 + calendar-predicate fixups): SS/SF drives from a started @@ -180,8 +202,8 @@ Expected output (Node 18+, Python 3.8+): ``` Python reference: /python_reference/cpm.py - bytes: 136967 - sha-256: 83c6db6f61b36d8c8c22fee59d37bfeb36d3d8bc131a3716793e930327301ed5 + bytes: 150186 + sha-256: a3ebb418739c36c4af6a9088a229cc81d3cae58d8f45efad8620e4d9b50799d1 --- F1 -- A->B->C linear, no cal --- PASS project_finish_num PASS project_finish diff --git a/python_reference/cpm.py b/python_reference/cpm.py index 0cb3435..aaa5f05 100644 --- a/python_reference/cpm.py +++ b/python_reference/cpm.py @@ -37,6 +37,11 @@ # (decode_clndr_data / decode_calendar_record), the Python parity twin # of the JS parseXER-side calendar decode and its # calendar-corrupt-p6-fallback forensic ALERT. +# 3. v2.9.44 (cross-calendar finish instants) is applied to this reference +# exactly as to the canonical engine: successors are driven from the +# predecessor's finish INSTANT and snapped onto their own calendar, +# lags are working time on the lag calendar from that instant, and the +# backward pass mirrors it. See CHANGELOG.md v2.9.44. # # This file is pinned by SHA-256 — see python_reference/README.md and the # hash printed by cpm-engine.crossval.js at startup. Any drift between this @@ -120,7 +125,7 @@ def _round_half_up_to(x, decimals=0): # dropping are executed. Only the completed-activity branch still emits neither # ff_signed nor ff_signed_working_days, and neither does the JS engine, so # those 6 comparisons are absent on both sides rather than one. -ENGINE_VERSION = '2.9.43' +ENGINE_VERSION = '2.9.44' # ============================================================================= @@ -749,6 +754,123 @@ def _retreat_workdays(end_num, n_days, calendar_info, *, alerts, ctx): return _num_from_date(start_d) +# ============================================================================= +# v2.9.44 — finish INSTANTS for cross-calendar logic (mirrors cpm-engine.js) +# ============================================================================= +# +# The engine carries an early finish as a BOUNDARY: the opening of the next +# working day on the FINISHING activity's own calendar (Monday for a Friday +# finish on Mon-Fri). P6 carries it as an INSTANT: the close of the last worked +# period (Friday 17:00). The two name the same moment only when the successor +# shares the calendar. A seven-day successor of that Friday finish starts on +# Saturday in P6; a successor whose calendar works the predecessor's holiday +# starts on the holiday; a successor with a blackout starts after the blackout. +# Measured on a 2,898-activity real export with five active calendars, every +# residual root divergence between the engine and P6's stored early dates was +# one of these shapes once the dates the file's own logic cannot produce were +# pinned (see test_cross_calendar_finish_instants_2026_09_15.py). +# +# The day-number representation stays: an instant is the calendar day whose +# opening it is (Friday 17:00 = Saturday's opening = Saturday's day number). +# Nodes carry both: `ef` / `ef_date` remain the boundary on the activity's own +# calendar (what every existing consumer reads), `ef_instant` is what +# successors are driven from. + +def _instant_of(dt_str): + """Day-number instant of a P6 date-time string. + + 'YYYY-MM-DD' (or a morning time) is the opening of that day; a time at or + after 12:00 is the close of that day, i.e. the opening of the next + calendar day. P6 writes finishes as 'YYYY-MM-DD 17:00' / '16:00' and + starts as '08:00' / '07:00', so noon separates the two shapes. + """ + if dt_str is None: + return 0 + s = str(dt_str).strip() + n = date_to_num(s) + if n <= 0: + return n + if len(s) >= 13 and s[10] in (' ', 'T') and s[11:13].isdigit(): + if int(s[11:13]) >= 12: + return n + 1 + return n + + +def _boundary_to_instant(boundary_num, calendar_info, *, alerts, ctx): + """Instant (opening of the calendar day after the last worked day) for a + working-day boundary on `calendar_info`. No calendar: the boundary itself. + """ + if boundary_num <= 0 or not calendar_info: + return boundary_num + last_worked = _retreat_workdays(boundary_num, 1, calendar_info, + alerts=alerts, ctx=ctx) + return last_worked + 1 + + +def _lag_from_instant(instant, lag, lag_cal, *, alerts, ctx): + """Consume `lag` working days on `lag_cal` starting at `instant`; returns + an instant. P6 semantics: a positive lag is working time counted from the + predecessor's finish instant on the lag calendar (Friday 17:00 + 1 day on + Mon-Fri is Monday 17:00), a negative lag is working time retreated from + it (Friday 17:00 - 1 day is Friday 08:00), zero leaves the instant alone. + Without a calendar the single ordinal-arithmetic call (and its ALERT) is + the same one the pre-instant walk made. + """ + if instant <= 0 or not lag_cal: + # Same single call, same ALERT, as the pre-instant walk made here. + return _advance_workdays(instant, lag, lag_cal, alerts=alerts, ctx=ctx) + n = _round_half_up(lag) + if n == 0: + return instant + if n > 0: + start = _advance_workdays(instant, 0, lag_cal, alerts=alerts, ctx=ctx) + boundary = _advance_workdays(start, lag, lag_cal, alerts=alerts, ctx=ctx) + return _boundary_to_instant(boundary, lag_cal, alerts=alerts, ctx=ctx) + return _advance_workdays(instant, lag, lag_cal, alerts=alerts, ctx=ctx) + + +def _snap_fwd(num, calendar_info, *, alerts, ctx): + """First working day of `calendar_info` at or after `num` (no-op without + a calendar, so the no-calendar path emits no extra ALERT).""" + if num <= 0 or not calendar_info: + return num + return _advance_workdays(num, 0, calendar_info, alerts=alerts, ctx=ctx) + + +def _snap_bwd(num, calendar_info, *, alerts, ctx): + """Last working day of `calendar_info` at or before `num` (no-op without + a calendar).""" + if num <= 0 or not calendar_info: + return num + return _retreat_workdays(num, 0, calendar_info, alerts=alerts, ctx=ctx) + + +def _lag_back_from_instant(instant, lag, lag_cal, *, alerts, ctx): + """Backward mirror of _lag_from_instant: the instant `lag` working days of + `lag_cal` BEFORE `instant` (a positive lag retreats, a negative lag - a + lead - advances by the forward rule, zero leaves the instant alone). + Without a calendar the single ordinal-arithmetic call (and its ALERT) is + the same one the pre-instant walk made. + """ + if instant <= 0 or not lag_cal: + return _retreat_workdays(instant, lag, lag_cal, alerts=alerts, ctx=ctx) + n = _round_half_up(lag) + if n == 0: + return instant + if n > 0: + return _retreat_workdays(instant, lag, lag_cal, alerts=alerts, ctx=ctx) + return _lag_from_instant(instant, -lag, lag_cal, alerts=alerts, ctx=ctx) + + +def _finish_instant(pnode): + """The instant a successor is driven from: the node's finish instant when + the forward pass (or a timed actual finish) stamped one, else its boundary.""" + inst = pnode.get('ef_instant') + if inst is not None and inst > 0: + return inst + return pnode['ef'] + + # ============================================================================= # Constraint clamp helpers (mirrors cpm-engine.js v2.9.7) # ============================================================================= @@ -1014,7 +1136,10 @@ def compute_cpm(activities, relationships, data_date='', cal_map=None, Raises: ValueError: if the network contains a cycle. """ - dd_num = date_to_num(data_date) if data_date else 0 + # v2.9.44 — the data date is an INSTANT too: 'YYYY-MM-DD 17:00' (the close + # of that day) floors remaining work on the NEXT day, exactly as P6 does; + # a date-only or morning value is the opening of that day, unchanged. + dd_num = _instant_of(data_date) if data_date else 0 cal_map = cal_map or {} alerts = [] # B4 (P6 alignment wave 2026-08-11): both P6 scheduling modes @@ -1132,8 +1257,21 @@ def compute_cpm(activities, relationships, data_date='', cal_map=None, is_complete = bool(a.get('is_complete', False)) or bool(actual_finish) es = date_to_num(a.get('early_start', '')) if a.get('early_start') else 0 ef = date_to_num(a.get('early_finish', '')) if a.get('early_finish') else 0 + # v2.9.44 — a completed predecessor drives its successors from its + # actual-finish INSTANT. The P6 string carries the time ('2027-03-05 + # 17:00' is the close of Friday, so a Mon-Fri successor starts Monday + # and a seven-day one Saturday); a date-only value has no instant and + # keeps the legacy reading, the finish day itself. The node keeps the + # date part for display, as before. + ef_instant = 0 if is_complete and actual_finish: ef = date_to_num(actual_finish) + ef_instant = _instant_of(actual_finish) + elif is_complete: + ef_instant = ef + actual_start = str(actual_start).strip()[:10] + actual_finish = str(actual_finish).strip()[:10] + if is_complete and actual_finish: if actual_start: es = date_to_num(actual_start) else: @@ -1196,6 +1334,12 @@ def compute_cpm(activities, relationships, data_date='', cal_map=None, # v2.9.14 F14 backport — driving_predecessor populated by the # forward pass; init None. 'driving_predecessor': None, + # v2.9.44 — finish instant (stamped by the forward pass for + # incomplete nodes) and the P6 task type, which decides whether a + # zero-duration node sits at its driving instant (TT_FinMile) or + # at its own calendar's next working start (everything else). + 'ef_instant': ef_instant, + 'task_type': _tt, } # v2.9.42 PAIRED FIX — missing-data-date gate. Mirrors cpm-engine.js. @@ -1407,6 +1551,9 @@ def _start_drive_src_for(pnode): # Out: dict {code, type, lag_days} or None. driving_pred = None _restart_max_drive = 0 + # v2.9.44 — (snapped drive, instant) per relationship, so a finish + # milestone can sit at the instant that actually drove it. + _drive_instants = [] for p in preds: pnode = nodes.get(p['from_code']) if not pnode: @@ -1417,44 +1564,60 @@ def _start_drive_src_for(pnode): # v2.9.42 PAIRED FIX - the LAG walk runs on the relationship-lag # calendar; the DURATION walk stays on this activity's own calendar. lag_cal = _lag_cal_for(pnode, node) + # v2.9.44 — every drive is computed as an INSTANT (the + # predecessor's finish or start instant, the lag consumed as + # working time on the lag calendar from that instant) and then + # snapped onto THIS activity's own calendar, which is where P6 + # puts an early start. Before this, the predecessor's boundary + # was handed over as-is and the lag walk snapped onto the LAG + # calendar, so a successor could start on a day its own calendar + # does not work, and a seven-day successor of a Friday finish + # started on Monday instead of Saturday. if t == 'FS': - anchor = pnode['ef'] - drive = _advance_workdays(anchor, lag, lag_cal, - alerts=alerts, - ctx=f'FS lag {pnode["code"]}->{code}') + _ctx = f'FS lag {pnode["code"]}->{code}' + drive_instant = _lag_from_instant( + _finish_instant(pnode), lag, lag_cal, alerts=alerts, ctx=_ctx) + drive = _snap_fwd(drive_instant, node_cal, alerts=alerts, ctx=_ctx) + _drive_instants.append((drive, drive_instant)) elif t == 'SS': # D1 — SS drives from the predecessor's remaining-start # reference (restart for a started incomplete pred; es # otherwise). See _start_drive_src_for. - anchor = _start_drive_src_for(pnode) - drive = _advance_workdays(anchor, lag, lag_cal, - alerts=alerts, - ctx=f'SS lag {pnode["code"]}->{code}') + _ctx = f'SS lag {pnode["code"]}->{code}' + drive_instant = _lag_from_instant( + _start_drive_src_for(pnode), lag, lag_cal, alerts=alerts, ctx=_ctx) + drive = _snap_fwd(drive_instant, node_cal, alerts=alerts, ctx=_ctx) + _drive_instants.append((drive, drive_instant)) elif t == 'FF': - succ_ef_anchor = _advance_workdays( - pnode['ef'], lag, lag_cal, - alerts=alerts, ctx=f'FF lag {pnode["code"]}->{code}') + _ctx = f'FF lag {pnode["code"]}->{code}' + anchor_instant = _lag_from_instant( + _finish_instant(pnode), lag, lag_cal, alerts=alerts, ctx=_ctx) + succ_ef_anchor = _snap_fwd(anchor_instant, node_cal, alerts=alerts, ctx=_ctx) drive = _retreat_workdays( succ_ef_anchor, node['duration_days'], node_cal, alerts=alerts, ctx=f'FF duration {code}') this_anchor_ef = succ_ef_anchor + _drive_instants.append((succ_ef_anchor, anchor_instant)) elif t == 'SF': # D1 — SF anchors from the predecessor's remaining-start # reference, same as SS. INFERRED for SF specifically: the # corpus carries no discriminating SF instance; adopted by # symmetry with the measured SS rule. - succ_ef_anchor = _advance_workdays( - _start_drive_src_for(pnode), lag, lag_cal, - alerts=alerts, ctx=f'SF lag {pnode["code"]}->{code}') + _ctx = f'SF lag {pnode["code"]}->{code}' + anchor_instant = _lag_from_instant( + _start_drive_src_for(pnode), lag, lag_cal, alerts=alerts, ctx=_ctx) + succ_ef_anchor = _snap_fwd(anchor_instant, node_cal, alerts=alerts, ctx=_ctx) drive = _retreat_workdays( succ_ef_anchor, node['duration_days'], node_cal, alerts=alerts, ctx=f'SF duration {code}') this_anchor_ef = succ_ef_anchor + _drive_instants.append((succ_ef_anchor, anchor_instant)) else: - anchor = pnode['ef'] - drive = _advance_workdays(anchor, lag, lag_cal, - alerts=alerts, - ctx=f'FS-default lag {pnode["code"]}->{code}') + _ctx = f'FS-default lag {pnode["code"]}->{code}' + drive_instant = _lag_from_instant( + _finish_instant(pnode), lag, lag_cal, alerts=alerts, ctx=_ctx) + drive = _snap_fwd(drive_instant, node_cal, alerts=alerts, ctx=_ctx) + _drive_instants.append((drive, drive_instant)) # P6 forward-pass semantics: pred logic cannot override actual_start. if has_actual_start: if drive > max_es and driving_pred is None: @@ -1770,6 +1933,33 @@ def _start_drive_src_for(pnode): node['es'] = _bes driving_pred = {'type': 'CONSTRAINT', 'date': _mfc['date']} + # v2.9.44 — the finish INSTANT successors are driven from. A bar + # (remaining bar for started work) closes at the end of its last + # worked day: the instant is the opening of the following calendar + # day, whatever this activity's calendar says about that day. A + # zero-duration node sits at its own snapped day, except a finish + # milestone (TT_FinMile), which P6 places AT the instant that drove + # it (Friday 17:00 when its predecessor finished Friday), so a + # seven-day successor of the milestone starts Saturday; when nothing + # but the data date drove it, that instant is the data date itself. + if has_actual_start and not node['is_complete'] and _rem_provided: + _bar_days = _rem_dur + else: + _bar_days = node['duration_days'] + if _bar_days > 0: + node['ef_instant'] = _boundary_to_instant( + node['ef'], node_cal, alerts=alerts, ctx=f'finish instant {code}') + else: + node['ef_instant'] = node['ef'] + if (node.get('task_type') == 'TT_FinMile' and not has_actual_start + and node['ef'] == node['es']): + _cands = [inst for (v, inst) in _drive_instants if v == node['ef']] + if _cands: + node['ef_instant'] = max(_cands) + elif dd_num > 0 and node['ef'] == _snap_fwd( + dd_num, node_cal, alerts=alerts, ctx=f'finish instant {code}'): + node['ef_instant'] = dd_num + # v2.9.15 P2 (F14-4) backport — DATA_DATE-driven driver. When no pred # and no constraint won, but max_es == dd_num AND the activity has preds, # set driving_predecessor to a {type:'DATA_DATE', date} sentinel. @@ -1823,6 +2013,24 @@ def _seed_lf_for(n): return _advance_workdays(_num_from_date(d), 1, n_cal, alerts=alerts, ctx=f'seed-LF {n["code"]}') + # v2.9.44 — a successor's late-finish INSTANT: the close of its last + # late-worked day (the boundary retreated to that day, plus one calendar + # day), or the late finish itself for a zero-duration node. Mirrors the + # forward ef_instant so FF / SF backward bounds retreat from the same + # kind of instant the forward pass advanced from. + def _lf_instant_of(snode): + _sr = snode.get('remaining_duration') + if (snode.get('actual_start') and not snode['is_complete'] + and _sr is not None and math.isfinite(_sr) and _sr >= 0): + _s_bar = _sr + else: + _s_bar = snode['duration_days'] + if _s_bar > 0: + return _boundary_to_instant( + snode['lf'], _cal_for(snode), + alerts=alerts, ctx=f'late finish instant {snode["code"]}') + return snode['lf'] + # Backward Pass for n in nodes.values(): n_cal = cal_map.get(n.get('clndr_id', '')) if n.get('clndr_id') else None @@ -1882,26 +2090,44 @@ def _seed_lf_for(n): lag = s['lag_days'] drive = None ls_bound = None + # v2.9.44 — the backward walk mirrors the forward one in + # INSTANTS: the successor's late start is the instant it + # must not start after (its late finish instant is the close + # of its last late-worked day), the lag is retreated as + # working time on the lag calendar from that instant, and the + # result becomes THIS activity's bound on its own calendar - + # the boundary after its latest workable day for a finish + # bound (FS / FF), its latest workable day for a start bound + # (SS / SF). Without this mirror the forward instants left the + # two walks non-inverse: a Mon-Fri predecessor of a seven-day + # successor was handed a Saturday late finish against its + # Monday early-finish boundary and reported two days of + # negative float in a network with no constraint. if t == 'FS': - drive = _retreat_workdays( - snode['ls'], lag, s_cal, - alerts=alerts, ctx=f'backward FS lag {code}->{snode["code"]}') + _ctx = f'backward FS lag {code}->{snode["code"]}' + _inst = _lag_back_from_instant(snode['ls'], lag, s_cal, + alerts=alerts, ctx=_ctx) + drive = _snap_fwd(_inst, node_cal, alerts=alerts, ctx=_ctx) elif t == 'SS': - ls_bound = _retreat_workdays( - snode['ls'], lag, s_cal, - alerts=alerts, ctx=f'backward SS lag {code}->{snode["code"]}') + _ctx = f'backward SS lag {code}->{snode["code"]}' + _inst = _lag_back_from_instant(snode['ls'], lag, s_cal, + alerts=alerts, ctx=_ctx) + ls_bound = _snap_bwd(_inst, node_cal, alerts=alerts, ctx=_ctx) elif t == 'FF': - drive = _retreat_workdays( - snode['lf'], lag, s_cal, - alerts=alerts, ctx=f'backward FF lag {code}->{snode["code"]}') + _ctx = f'backward FF lag {code}->{snode["code"]}' + _inst = _lag_back_from_instant(_lf_instant_of(snode), lag, s_cal, + alerts=alerts, ctx=_ctx) + drive = _snap_fwd(_inst, node_cal, alerts=alerts, ctx=_ctx) elif t == 'SF': - ls_bound = _retreat_workdays( - snode['lf'], lag, s_cal, - alerts=alerts, ctx=f'backward SF lag {code}->{snode["code"]}') + _ctx = f'backward SF lag {code}->{snode["code"]}' + _inst = _lag_back_from_instant(_lf_instant_of(snode), lag, s_cal, + alerts=alerts, ctx=_ctx) + ls_bound = _snap_bwd(_inst, node_cal, alerts=alerts, ctx=_ctx) else: - drive = _retreat_workdays( - snode['ls'], lag, s_cal, - alerts=alerts, ctx=f'backward default {code}->{snode["code"]}') + _ctx = f'backward default {code}->{snode["code"]}' + _inst = _lag_back_from_instant(snode['ls'], lag, s_cal, + alerts=alerts, ctx=_ctx) + drive = _snap_fwd(_inst, node_cal, alerts=alerts, ctx=_ctx) if drive is not None and (min_lf is None or drive < min_lf): min_lf = drive if ls_bound is not None and (min_ls_bound is None or ls_bound < min_ls_bound): @@ -2020,10 +2246,22 @@ def _seed_lf_for(n): n['ef'] = n['lf'] # Round 6 — int 0 for JSON cross-engine parity (was 0.0). n['tf'] = 0 + # v2.9.44 — the finish instant slides with the finish. + _alap_cal = cal_map.get(n.get('clndr_id', '')) if n.get('clndr_id') else None + if n['ef'] > n['es']: + n['ef_instant'] = _boundary_to_instant( + n['ef'], _alap_cal, alerts=alerts, ctx=f'finish instant {c}') + else: + n['ef_instant'] = n['ef'] for n in nodes.values(): n['es_date'] = num_to_date(n['es']) n['ef_date'] = num_to_date(n['ef']) + # v2.9.44 — the finish instant beside the boundary: the calendar day + # whose opening the finish is (Saturday for a Friday 17:00 finish). + if not n.get('ef_instant'): + n['ef_instant'] = n['ef'] + n['ef_instant_date'] = num_to_date(n['ef_instant']) if n.get('actual_start') and not n['is_complete']: # B4 - display LS is the actual start; remaining-late calculus # exposed separately (mirrors the P6 grid and the JS emitter). diff --git a/validation/crossval-summary.json b/validation/crossval-summary.json index e1c8c9d..478b78c 100644 --- a/validation/crossval-summary.json +++ b/validation/crossval-summary.json @@ -1,6 +1,6 @@ { - "engine_version": "2.9.43", - "generated_utc": "2026-09-08T19:54:34.311Z", + "engine_version": "2.9.44", + "generated_utc": "2026-09-16T02:14:19.197Z", "fixtures": { "total": 46, "passed": 46, diff --git a/validation/p6-comparison/cases/01-fs-chain/README.md b/validation/p6-comparison/cases/01-fs-chain/README.md index 4ab5c17..cf0095f 100644 --- a/validation/p6-comparison/cases/01-fs-chain/README.md +++ b/validation/p6-comparison/cases/01-fs-chain/README.md @@ -16,7 +16,7 @@ A starts on dataDate (2026-01-05 = Mon), ends after 5 wd. B starts immediately a 4. F9 to schedule. 5. Capture ES/EF/LS/LF/TF/FF from the activity table. -## Engine output (produced by engine v2.9.38) +## Engine output (produced by engine v2.9.44) Project finish: `2026-01-19` diff --git a/validation/p6-comparison/cases/01-fs-chain/engine-output.json b/validation/p6-comparison/cases/01-fs-chain/engine-output.json index 5b4b5e4..474e4bd 100644 --- a/validation/p6-comparison/cases/01-fs-chain/engine-output.json +++ b/validation/p6-comparison/cases/01-fs-chain/engine-output.json @@ -14,14 +14,23 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2201, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": null, "_seedLF": 2210, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-05", "ef_date": "2026-01-12", + "ef_instant_date": "2026-01-10", "ls_date": "2026-01-05", "lf_date": "2026-01-12", + "ef_last_worked_date": "2026-01-09", + "lf_last_worked_date": "2026-01-09", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff": 0, @@ -42,6 +51,8 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2206, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": { @@ -50,10 +61,17 @@ "lag_days": 0 }, "_seedLF": 2210, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-12", "ef_date": "2026-01-15", + "ef_instant_date": "2026-01-15", "ls_date": "2026-01-12", "lf_date": "2026-01-15", + "ef_last_worked_date": "2026-01-14", + "lf_last_worked_date": "2026-01-14", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff": 0, @@ -74,6 +92,8 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2208, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": { @@ -82,10 +102,17 @@ "lag_days": 0 }, "_seedLF": 2210, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-15", "ef_date": "2026-01-19", + "ef_instant_date": "2026-01-17", "ls_date": "2026-01-15", "lf_date": "2026-01-19", + "ef_last_worked_date": "2026-01-16", + "lf_last_worked_date": "2026-01-16", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff_signed_working_days": 0, @@ -112,13 +139,19 @@ "C" ], "alerts": [], + "excluded_by_task_type": [], + "excludedByTaskType": [], "manifest": { - "engine_version": "2.9.38", + "engine_version": "2.9.44", "method_id": "computeCPM", "activity_count": 3, "relationship_count": 2, "data_date": "2026-01-05", "calendar_count": 1, - "computed_at": "2026-08-11T18:13:30.418Z" + "finish_boundary_convention": "exclusive", + "finish_boundary_note": "ef_date / lf_date are EXCLUSIVE (the opening of the first working day AFTER the last day worked). P6 prints the LAST WORKED DAY. Both name the same instant; ef_last_worked_date and lf_last_worked_date carry the inclusive form for reports.", + "relationship_lag_calendar": "successor", + "float_type": "FT_FF", + "computed_at": "2026-09-16T02:15:31.656Z" } } diff --git a/validation/p6-comparison/cases/02-ss-with-lag/README.md b/validation/p6-comparison/cases/02-ss-with-lag/README.md index 23bab98..4723715 100644 --- a/validation/p6-comparison/cases/02-ss-with-lag/README.md +++ b/validation/p6-comparison/cases/02-ss-with-lag/README.md @@ -16,7 +16,7 @@ P6-captured 2026-08-11 (capture 9b748cc). This case FAILED that capture. The cap 4. F9 to schedule. 5. Capture columns and compare. -## Engine output (produced by engine v2.9.38) +## Engine output (produced by engine v2.9.44) Project finish: `2026-01-19` diff --git a/validation/p6-comparison/cases/02-ss-with-lag/engine-output.json b/validation/p6-comparison/cases/02-ss-with-lag/engine-output.json index f3e8591..a819386 100644 --- a/validation/p6-comparison/cases/02-ss-with-lag/engine-output.json +++ b/validation/p6-comparison/cases/02-ss-with-lag/engine-output.json @@ -14,14 +14,23 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2208, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": null, "_seedLF": 2210, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-05", "ef_date": "2026-01-19", + "ef_instant_date": "2026-01-17", "ls_date": "2026-01-05", "lf_date": "2026-01-19", + "ef_last_worked_date": "2026-01-16", + "lf_last_worked_date": "2026-01-16", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff": 0, @@ -42,6 +51,8 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2207, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": { @@ -50,10 +61,17 @@ "lag_days": 5 }, "_seedLF": 2210, + "tf_finish": 3, + "tf_start": 1, "es_date": "2026-01-12", "ef_date": "2026-01-16", + "ef_instant_date": "2026-01-16", "ls_date": "2026-01-13", "lf_date": "2026-01-19", + "ef_last_worked_date": "2026-01-15", + "lf_last_worked_date": "2026-01-16", + "tf_finish_working_days": 1, + "tf_start_working_days": 1, "tf_working_days": 1, "ff_signed": 3, "ff_signed_working_days": 1, @@ -76,13 +94,19 @@ "B" ], "alerts": [], + "excluded_by_task_type": [], + "excludedByTaskType": [], "manifest": { - "engine_version": "2.9.38", + "engine_version": "2.9.44", "method_id": "computeCPM", "activity_count": 2, "relationship_count": 1, "data_date": "2026-01-05", "calendar_count": 1, - "computed_at": "2026-08-11T18:13:30.422Z" + "finish_boundary_convention": "exclusive", + "finish_boundary_note": "ef_date / lf_date are EXCLUSIVE (the opening of the first working day AFTER the last day worked). P6 prints the LAST WORKED DAY. Both name the same instant; ef_last_worked_date and lf_last_worked_date carry the inclusive form for reports.", + "relationship_lag_calendar": "successor", + "float_type": "FT_FF", + "computed_at": "2026-09-16T02:15:31.660Z" } } diff --git a/validation/p6-comparison/cases/03-ff-with-lag/README.md b/validation/p6-comparison/cases/03-ff-with-lag/README.md index 21af06a..220db7b 100644 --- a/validation/p6-comparison/cases/03-ff-with-lag/README.md +++ b/validation/p6-comparison/cases/03-ff-with-lag/README.md @@ -16,7 +16,7 @@ A starts dataDate (Mon Jan 5), 5 wd → A.EF = Fri Jan 9. B has no FS predecesso 4. F9 to schedule. 5. Capture columns and compare. -## Engine output (produced by engine v2.9.38) +## Engine output (produced by engine v2.9.44) Project finish: `2026-01-15` diff --git a/validation/p6-comparison/cases/03-ff-with-lag/engine-output.json b/validation/p6-comparison/cases/03-ff-with-lag/engine-output.json index 0898fe6..d5c0072 100644 --- a/validation/p6-comparison/cases/03-ff-with-lag/engine-output.json +++ b/validation/p6-comparison/cases/03-ff-with-lag/engine-output.json @@ -14,14 +14,23 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2201, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": null, "_seedLF": 2206, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-05", "ef_date": "2026-01-12", + "ef_instant_date": "2026-01-10", "ls_date": "2026-01-05", "lf_date": "2026-01-12", + "ef_last_worked_date": "2026-01-09", + "lf_last_worked_date": "2026-01-09", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff": 0, @@ -42,6 +51,8 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2206, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": { @@ -50,10 +61,17 @@ "lag_days": 3 }, "_seedLF": 2206, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-09", "ef_date": "2026-01-15", + "ef_instant_date": "2026-01-15", "ls_date": "2026-01-09", "lf_date": "2026-01-15", + "ef_last_worked_date": "2026-01-14", + "lf_last_worked_date": "2026-01-14", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff_signed_working_days": 0, @@ -77,13 +95,19 @@ "B" ], "alerts": [], + "excluded_by_task_type": [], + "excludedByTaskType": [], "manifest": { - "engine_version": "2.9.38", + "engine_version": "2.9.44", "method_id": "computeCPM", "activity_count": 2, "relationship_count": 1, "data_date": "2026-01-05", "calendar_count": 1, - "computed_at": "2026-08-11T18:13:30.425Z" + "finish_boundary_convention": "exclusive", + "finish_boundary_note": "ef_date / lf_date are EXCLUSIVE (the opening of the first working day AFTER the last day worked). P6 prints the LAST WORKED DAY. Both name the same instant; ef_last_worked_date and lf_last_worked_date carry the inclusive form for reports.", + "relationship_lag_calendar": "successor", + "float_type": "FT_FF", + "computed_at": "2026-09-16T02:15:31.663Z" } } diff --git a/validation/p6-comparison/cases/04-sf-edge-case/README.md b/validation/p6-comparison/cases/04-sf-edge-case/README.md index 74dc6e3..7bfe663 100644 --- a/validation/p6-comparison/cases/04-sf-edge-case/README.md +++ b/validation/p6-comparison/cases/04-sf-edge-case/README.md @@ -16,7 +16,7 @@ fitted to capture 9b748cc (2026-08-11), not independently validated: the SF succ 4. F9 to schedule. 5. NOTE: SF behavior in P6 can vary with retained-logic vs progress-override settings. Use retained-logic. -## Engine output (produced by engine v2.9.38) +## Engine output (produced by engine v2.9.44) Project finish: `2026-01-12` diff --git a/validation/p6-comparison/cases/04-sf-edge-case/engine-output.json b/validation/p6-comparison/cases/04-sf-edge-case/engine-output.json index dbde917..fdc5af5 100644 --- a/validation/p6-comparison/cases/04-sf-edge-case/engine-output.json +++ b/validation/p6-comparison/cases/04-sf-edge-case/engine-output.json @@ -14,14 +14,23 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2201, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": null, "_seedLF": 2203, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-05", "ef_date": "2026-01-12", + "ef_instant_date": "2026-01-10", "ls_date": "2026-01-05", "lf_date": "2026-01-12", + "ef_last_worked_date": "2026-01-09", + "lf_last_worked_date": "2026-01-09", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 3, "ff": 3, @@ -42,6 +51,8 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2199, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": { @@ -49,10 +60,17 @@ "date": "2026-01-05" }, "_seedLF": 2203, + "tf_finish": 4, + "tf_start": 2, "es_date": "2026-01-05", "ef_date": "2026-01-08", + "ef_instant_date": "2026-01-08", "ls_date": "2026-01-07", "lf_date": "2026-01-12", + "ef_last_worked_date": "2026-01-07", + "lf_last_worked_date": "2026-01-09", + "tf_finish_working_days": 2, + "tf_start_working_days": 2, "tf_working_days": 2, "ff_signed": 4, "ff_signed_working_days": 2, @@ -75,13 +93,19 @@ "B" ], "alerts": [], + "excluded_by_task_type": [], + "excludedByTaskType": [], "manifest": { - "engine_version": "2.9.38", + "engine_version": "2.9.44", "method_id": "computeCPM", "activity_count": 2, "relationship_count": 1, "data_date": "2026-01-05", "calendar_count": 1, - "computed_at": "2026-08-11T18:13:30.427Z" + "finish_boundary_convention": "exclusive", + "finish_boundary_note": "ef_date / lf_date are EXCLUSIVE (the opening of the first working day AFTER the last day worked). P6 prints the LAST WORKED DAY. Both name the same instant; ef_last_worked_date and lf_last_worked_date carry the inclusive form for reports.", + "relationship_lag_calendar": "successor", + "float_type": "FT_FF", + "computed_at": "2026-09-16T02:15:31.665Z" } } diff --git a/validation/p6-comparison/cases/05-negative-float/README.md b/validation/p6-comparison/cases/05-negative-float/README.md index 647a6cc..905a422 100644 --- a/validation/p6-comparison/cases/05-negative-float/README.md +++ b/validation/p6-comparison/cases/05-negative-float/README.md @@ -16,7 +16,7 @@ fitted to capture 9b748cc (2026-08-11), not independently validated: constraint 4. F9 — schedule should show negative TF on A and B. 5. Capture ES/EF/LS/LF/TF; TF should be NEGATIVE. -## Engine output (produced by engine v2.9.38) +## Engine output (produced by engine v2.9.44) Project finish: `2026-01-21` diff --git a/validation/p6-comparison/cases/05-negative-float/engine-output.json b/validation/p6-comparison/cases/05-negative-float/engine-output.json index f720c27..3a619f9 100644 --- a/validation/p6-comparison/cases/05-negative-float/engine-output.json +++ b/validation/p6-comparison/cases/05-negative-float/engine-output.json @@ -14,14 +14,23 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2206, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": null, "_seedLF": 2212, + "tf_finish": -9, + "tf_start": -11, "es_date": "2026-01-05", "ef_date": "2026-01-15", + "ef_instant_date": "2026-01-15", "ls_date": "2025-12-25", "lf_date": "2026-01-06", + "ef_last_worked_date": "2026-01-14", + "lf_last_worked_date": "2026-01-05", + "tf_finish_working_days": -7, + "tf_start_working_days": -7, "tf_working_days": -7, "ff_signed": 0, "ff": 0, @@ -42,6 +51,8 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2212, + "task_type": "", "constraint": { "type": "FNLT", "date": "2026-01-12" @@ -53,10 +64,17 @@ "lag_days": 0 }, "_seedLF": 2212, + "tf_finish": -9, + "tf_start": -9, "es_date": "2026-01-15", "ef_date": "2026-01-21", + "ef_instant_date": "2026-01-21", "ls_date": "2026-01-06", "lf_date": "2026-01-12", + "ef_last_worked_date": "2026-01-20", + "lf_last_worked_date": "2026-01-09", + "tf_finish_working_days": -7, + "tf_start_working_days": -7, "tf_working_days": -7, "ff_signed": -9, "ff_signed_working_days": -7, @@ -86,13 +104,19 @@ "message": "FNLT on B violated: EF=2026-01-21 is after constraint date 2026-01-12" } ], + "excluded_by_task_type": [], + "excludedByTaskType": [], "manifest": { - "engine_version": "2.9.38", + "engine_version": "2.9.44", "method_id": "computeCPM", "activity_count": 2, "relationship_count": 1, "data_date": "2026-01-05", "calendar_count": 1, - "computed_at": "2026-08-11T18:13:30.429Z" + "finish_boundary_convention": "exclusive", + "finish_boundary_note": "ef_date / lf_date are EXCLUSIVE (the opening of the first working day AFTER the last day worked). P6 prints the LAST WORKED DAY. Both name the same instant; ef_last_worked_date and lf_last_worked_date carry the inclusive form for reports.", + "relationship_lag_calendar": "successor", + "float_type": "FT_FF", + "computed_at": "2026-09-16T02:15:31.667Z" } } diff --git a/validation/p6-comparison/cases/06-multiple-calendars/README.md b/validation/p6-comparison/cases/06-multiple-calendars/README.md index 01e723d..f397ce4 100644 --- a/validation/p6-comparison/cases/06-multiple-calendars/README.md +++ b/validation/p6-comparison/cases/06-multiple-calendars/README.md @@ -16,7 +16,7 @@ fitted to capture 9b748cc (2026-08-11), not independently validated: no relation 4. Both start on 2026-01-05. 5. F9 — verify B finishes earlier than A by 2 calendar days (1 wd on the 6-day cal). -## Engine output (produced by engine v2.9.38) +## Engine output (produced by engine v2.9.44) Project finish: `2026-01-19` diff --git a/validation/p6-comparison/cases/06-multiple-calendars/engine-output.json b/validation/p6-comparison/cases/06-multiple-calendars/engine-output.json index cd3d0b2..f1e3773 100644 --- a/validation/p6-comparison/cases/06-multiple-calendars/engine-output.json +++ b/validation/p6-comparison/cases/06-multiple-calendars/engine-output.json @@ -14,14 +14,23 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2208, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": null, "_seedLF": 2210, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-05", "ef_date": "2026-01-19", + "ef_instant_date": "2026-01-17", "ls_date": "2026-01-05", "lf_date": "2026-01-19", + "ef_last_worked_date": "2026-01-16", + "lf_last_worked_date": "2026-01-16", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff_signed_working_days": 0, @@ -42,14 +51,23 @@ "actual_start": "", "actual_finish": "", "clndr_id": "SIXDAY", + "ef_instant": 2207, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": null, "_seedLF": 2208, + "tf_finish": 1, + "tf_start": 1, "es_date": "2026-01-05", "ef_date": "2026-01-16", + "ef_instant_date": "2026-01-16", "ls_date": "2026-01-06", "lf_date": "2026-01-17", + "ef_last_worked_date": "2026-01-15", + "lf_last_worked_date": "2026-01-16", + "tf_finish_working_days": 1, + "tf_start_working_days": 1, "tf_working_days": 1, "ff_signed": 1, "ff_signed_working_days": 1, @@ -72,13 +90,19 @@ "B" ], "alerts": [], + "excluded_by_task_type": [], + "excludedByTaskType": [], "manifest": { - "engine_version": "2.9.38", + "engine_version": "2.9.44", "method_id": "computeCPM", "activity_count": 2, "relationship_count": 0, "data_date": "2026-01-05", "calendar_count": 2, - "computed_at": "2026-08-11T18:13:30.431Z" + "finish_boundary_convention": "exclusive", + "finish_boundary_note": "ef_date / lf_date are EXCLUSIVE (the opening of the first working day AFTER the last day worked). P6 prints the LAST WORKED DAY. Both name the same instant; ef_last_worked_date and lf_last_worked_date carry the inclusive form for reports.", + "relationship_lag_calendar": "successor", + "float_type": "FT_FF", + "computed_at": "2026-09-16T02:15:31.669Z" } } diff --git a/validation/p6-comparison/cases/07-ontario-holidays/README.md b/validation/p6-comparison/cases/07-ontario-holidays/README.md index 652a250..df851b2 100644 --- a/validation/p6-comparison/cases/07-ontario-holidays/README.md +++ b/validation/p6-comparison/cases/07-ontario-holidays/README.md @@ -24,7 +24,7 @@ A starts Mon Jan 5 2026, 90 wd Mon-Fri on CA-ON calendar. Crosses Family Day (3r 3. F9. 4. Verify A.EF matches the engine output within +/- 0 wd. -## Engine output (produced by engine v2.9.38) +## Engine output (produced by engine v2.9.44) Project finish: `2026-05-13` diff --git a/validation/p6-comparison/cases/07-ontario-holidays/engine-output.json b/validation/p6-comparison/cases/07-ontario-holidays/engine-output.json index 377e87c..39c3adf 100644 --- a/validation/p6-comparison/cases/07-ontario-holidays/engine-output.json +++ b/validation/p6-comparison/cases/07-ontario-holidays/engine-output.json @@ -14,14 +14,23 @@ "actual_start": "", "actual_finish": "", "clndr_id": "CA_ON", + "ef_instant": 2324, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": null, "_seedLF": 2324, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-05", "ef_date": "2026-05-13", + "ef_instant_date": "2026-05-13", "ls_date": "2026-01-05", "lf_date": "2026-05-13", + "ef_last_worked_date": "2026-05-12", + "lf_last_worked_date": "2026-05-12", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff_signed_working_days": 0, @@ -42,13 +51,19 @@ "A" ], "alerts": [], + "excluded_by_task_type": [], + "excludedByTaskType": [], "manifest": { - "engine_version": "2.9.38", + "engine_version": "2.9.44", "method_id": "computeCPM", "activity_count": 1, "relationship_count": 0, "data_date": "2026-01-05", "calendar_count": 1, - "computed_at": "2026-08-11T18:13:30.433Z" + "finish_boundary_convention": "exclusive", + "finish_boundary_note": "ef_date / lf_date are EXCLUSIVE (the opening of the first working day AFTER the last day worked). P6 prints the LAST WORKED DAY. Both name the same instant; ef_last_worked_date and lf_last_worked_date carry the inclusive form for reports.", + "relationship_lag_calendar": "successor", + "float_type": "FT_FF", + "computed_at": "2026-09-16T02:15:31.672Z" } } diff --git a/validation/p6-comparison/cases/08-in-progress-retained-logic/README.md b/validation/p6-comparison/cases/08-in-progress-retained-logic/README.md index d4809b3..c92a9af 100644 --- a/validation/p6-comparison/cases/08-in-progress-retained-logic/README.md +++ b/validation/p6-comparison/cases/08-in-progress-retained-logic/README.md @@ -17,7 +17,7 @@ A original duration 10 wd, started Tue Jan 6 2026 (1 wd late). remaining_duratio 5. Schedule under RETAINED LOGIC mode (NOT progress override). 6. Capture B's projected ES/EF. -## Engine output (produced by engine v2.9.38) +## Engine output (produced by engine v2.9.44) Project finish: `2026-01-28` diff --git a/validation/p6-comparison/cases/08-in-progress-retained-logic/engine-output.json b/validation/p6-comparison/cases/08-in-progress-retained-logic/engine-output.json index 961269b..15ca02f 100644 --- a/validation/p6-comparison/cases/08-in-progress-retained-logic/engine-output.json +++ b/validation/p6-comparison/cases/08-in-progress-retained-logic/engine-output.json @@ -15,18 +15,27 @@ "actual_start": "2026-01-06", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2212, + "task_type": "", "constraint": null, "constraint2": null, "restart": 2203, "driving_predecessor": null, "_seedLF": 2219, "remaining_late_start": 2203, + "tf_finish": 0, + "tf_start": 6, "es_date": "2026-01-06", "ef_date": "2026-01-21", + "ef_instant_date": "2026-01-21", "ls_date": "2026-01-06", "remaining_late_start_date": "2026-01-12", "restart_date": "2026-01-12", "lf_date": "2026-01-21", + "ef_last_worked_date": "2026-01-20", + "lf_last_worked_date": "2026-01-20", + "tf_finish_working_days": 0, + "tf_start_working_days": 4, "tf_working_days": 0, "ff_signed": 0, "ff": 0, @@ -47,6 +56,8 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2219, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": { @@ -55,10 +66,17 @@ "lag_days": 0 }, "_seedLF": 2219, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-21", "ef_date": "2026-01-28", + "ef_instant_date": "2026-01-28", "ls_date": "2026-01-21", "lf_date": "2026-01-28", + "ef_last_worked_date": "2026-01-27", + "lf_last_worked_date": "2026-01-27", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff_signed_working_days": 0, @@ -82,13 +100,19 @@ "B" ], "alerts": [], + "excluded_by_task_type": [], + "excludedByTaskType": [], "manifest": { - "engine_version": "2.9.38", + "engine_version": "2.9.44", "method_id": "computeCPM", "activity_count": 2, "relationship_count": 1, "data_date": "2026-01-12", "calendar_count": 1, - "computed_at": "2026-08-11T18:13:30.435Z" + "finish_boundary_convention": "exclusive", + "finish_boundary_note": "ef_date / lf_date are EXCLUSIVE (the opening of the first working day AFTER the last day worked). P6 prints the LAST WORKED DAY. Both name the same instant; ef_last_worked_date and lf_last_worked_date carry the inclusive form for reports.", + "relationship_lag_calendar": "successor", + "float_type": "FT_FF", + "computed_at": "2026-09-16T02:15:31.675Z" } } diff --git a/validation/p6-comparison/cases/09-completed-successor/README.md b/validation/p6-comparison/cases/09-completed-successor/README.md index 97de50a..c841243 100644 --- a/validation/p6-comparison/cases/09-completed-successor/README.md +++ b/validation/p6-comparison/cases/09-completed-successor/README.md @@ -16,7 +16,7 @@ B has actual_start 2025-12-15, actual_finish 2025-12-30 (in the past). A is plan 4. Verify A.LF does NOT pull back through B.actual_finish. 5. Engine emits "completed-succ-skipped-in-backward" INFO. -## Engine output (produced by engine v2.9.38) +## Engine output (produced by engine v2.9.44) Project finish: `2026-01-12` diff --git a/validation/p6-comparison/cases/09-completed-successor/engine-output.json b/validation/p6-comparison/cases/09-completed-successor/engine-output.json index 41b9bbf..9462830 100644 --- a/validation/p6-comparison/cases/09-completed-successor/engine-output.json +++ b/validation/p6-comparison/cases/09-completed-successor/engine-output.json @@ -14,13 +14,20 @@ "actual_start": "2025-12-15", "actual_finish": "2025-12-30", "clndr_id": "MONFRI", + "ef_instant": 2190, + "task_type": "", "constraint": null, "constraint2": null, "_seedLF": 2203, "es_date": "2025-12-15", "ef_date": "2025-12-30", + "ef_instant_date": "2025-12-30", "ls_date": "2025-12-15", "lf_date": "2025-12-30", + "ef_last_worked_date": "2025-12-29", + "lf_last_worked_date": "2025-12-29", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff": 0, "ff_working_days": 0 @@ -39,14 +46,23 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2201, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": null, "_seedLF": 2203, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-05", "ef_date": "2026-01-12", + "ef_instant_date": "2026-01-10", "ls_date": "2026-01-05", "lf_date": "2026-01-12", + "ef_last_worked_date": "2026-01-09", + "lf_last_worked_date": "2026-01-09", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff": 0, @@ -80,13 +96,19 @@ "message": "Activity B is in progress but 1 predecessor(s) have no actual_start (retained-logic anomaly): A" } ], + "excluded_by_task_type": [], + "excludedByTaskType": [], "manifest": { - "engine_version": "2.9.38", + "engine_version": "2.9.44", "method_id": "computeCPM", "activity_count": 2, "relationship_count": 1, "data_date": "2026-01-05", "calendar_count": 1, - "computed_at": "2026-08-11T18:13:30.437Z" + "finish_boundary_convention": "exclusive", + "finish_boundary_note": "ef_date / lf_date are EXCLUSIVE (the opening of the first working day AFTER the last day worked). P6 prints the LAST WORKED DAY. Both name the same instant; ef_last_worked_date and lf_last_worked_date carry the inclusive form for reports.", + "relationship_lag_calendar": "successor", + "float_type": "FT_FF", + "computed_at": "2026-09-16T02:15:31.676Z" } } diff --git a/validation/p6-comparison/cases/10-out-of-sequence-progress/README.md b/validation/p6-comparison/cases/10-out-of-sequence-progress/README.md index 8857077..3ca50ba 100644 --- a/validation/p6-comparison/cases/10-out-of-sequence-progress/README.md +++ b/validation/p6-comparison/cases/10-out-of-sequence-progress/README.md @@ -17,7 +17,7 @@ fitted to capture 9b748cc (2026-08-11), not independently validated: RETAINED LO 5. Data date = 2026-01-12. Retained logic mode. 6. P6 should flag the out-of-sequence relationship; engine emits ALERT. -## Engine output (produced by engine v2.9.38) +## Engine output (produced by engine v2.9.44) Project finish: `2026-01-29` diff --git a/validation/p6-comparison/cases/10-out-of-sequence-progress/engine-output.json b/validation/p6-comparison/cases/10-out-of-sequence-progress/engine-output.json index cc0fc5c..ccdd707 100644 --- a/validation/p6-comparison/cases/10-out-of-sequence-progress/engine-output.json +++ b/validation/p6-comparison/cases/10-out-of-sequence-progress/engine-output.json @@ -14,14 +14,23 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2215, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": null, "_seedLF": 2220, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-12", "ef_date": "2026-01-26", + "ef_instant_date": "2026-01-24", "ls_date": "2026-01-12", "lf_date": "2026-01-26", + "ef_last_worked_date": "2026-01-23", + "lf_last_worked_date": "2026-01-23", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff": 0, @@ -43,6 +52,8 @@ "actual_start": "2026-01-08", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2220, + "task_type": "", "constraint": null, "constraint2": null, "restart": 2217, @@ -53,12 +64,19 @@ }, "_seedLF": 2220, "remaining_late_start": 2217, + "tf_finish": 0, + "tf_start": 18, "es_date": "2026-01-08", "ef_date": "2026-01-29", + "ef_instant_date": "2026-01-29", "ls_date": "2026-01-08", "remaining_late_start_date": "2026-01-26", "restart_date": "2026-01-26", "lf_date": "2026-01-29", + "ef_last_worked_date": "2026-01-28", + "lf_last_worked_date": "2026-01-28", + "tf_finish_working_days": 0, + "tf_start_working_days": 12, "tf_working_days": 0, "ff_signed": 0, "ff_signed_working_days": 0, @@ -88,13 +106,19 @@ "message": "Activity B is in progress but 1 predecessor(s) have no actual_start (retained-logic anomaly): A" } ], + "excluded_by_task_type": [], + "excludedByTaskType": [], "manifest": { - "engine_version": "2.9.38", + "engine_version": "2.9.44", "method_id": "computeCPM", "activity_count": 2, "relationship_count": 1, "data_date": "2026-01-12", "calendar_count": 1, - "computed_at": "2026-08-11T18:13:30.439Z" + "finish_boundary_convention": "exclusive", + "finish_boundary_note": "ef_date / lf_date are EXCLUSIVE (the opening of the first working day AFTER the last day worked). P6 prints the LAST WORKED DAY. Both name the same instant; ef_last_worked_date and lf_last_worked_date carry the inclusive form for reports.", + "relationship_lag_calendar": "successor", + "float_type": "FT_FF", + "computed_at": "2026-09-16T02:15:31.678Z" } } diff --git a/validation/p6-comparison/cases/11-mandatory-start-finish/README.md b/validation/p6-comparison/cases/11-mandatory-start-finish/README.md index 5e8bf30..fcca978 100644 --- a/validation/p6-comparison/cases/11-mandatory-start-finish/README.md +++ b/validation/p6-comparison/cases/11-mandatory-start-finish/README.md @@ -16,7 +16,7 @@ A.ES = MS_Start = 2026-01-12 (pinned, ignoring dataDate floor). A.LS = MS_Start 4. F9 — both dates should be hard-pinned. 5. Verify the mandatory constraints pin LS/LF in the backward pass. -## Engine output (produced by engine v2.9.38) +## Engine output (produced by engine v2.9.44) Project finish: `2026-01-30` diff --git a/validation/p6-comparison/cases/11-mandatory-start-finish/engine-output.json b/validation/p6-comparison/cases/11-mandatory-start-finish/engine-output.json index 76650eb..bb4dd7d 100644 --- a/validation/p6-comparison/cases/11-mandatory-start-finish/engine-output.json +++ b/validation/p6-comparison/cases/11-mandatory-start-finish/engine-output.json @@ -14,21 +14,30 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2208, + "task_type": "", "constraint": { - "type": "MS_Start", + "type": "SO", "date": "2026-01-12" }, "constraint2": null, "driving_predecessor": { "type": "CONSTRAINT", - "constraint_type": "MS_Start", + "constraint_type": "SO", "date": "2026-01-12" }, "_seedLF": 2221, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-12", "ef_date": "2026-01-19", + "ef_instant_date": "2026-01-17", "ls_date": "2026-01-12", "lf_date": "2026-01-19", + "ef_last_worked_date": "2026-01-16", + "lf_last_worked_date": "2026-01-16", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 7, "ff": 7, @@ -49,8 +58,10 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2221, + "task_type": "", "constraint": { - "type": "MS_Finish", + "type": "FO", "date": "2026-01-30" }, "constraint2": null, @@ -59,10 +70,17 @@ "date": "2026-01-30" }, "_seedLF": 2221, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-26", "ef_date": "2026-01-30", + "ef_instant_date": "2026-01-30", "ls_date": "2026-01-26", "lf_date": "2026-01-30", + "ef_last_worked_date": "2026-01-29", + "lf_last_worked_date": "2026-01-29", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff_signed_working_days": 0, @@ -89,21 +107,27 @@ { "severity": "WARN", "context": "constraint-applied", - "message": "Mandatory Start on A pins ES to 2026-01-12" + "message": "Start On on A pushes ES from 2026-01-05 to 2026-01-12" }, { "severity": "WARN", "context": "constraint-applied", - "message": "Mandatory Finish on B pins EF to 2026-01-30" + "message": "Finish On on B pushes EF from 2026-01-23 to 2026-01-30" } ], + "excluded_by_task_type": [], + "excludedByTaskType": [], "manifest": { - "engine_version": "2.9.38", + "engine_version": "2.9.44", "method_id": "computeCPM", "activity_count": 2, "relationship_count": 1, "data_date": "2026-01-05", "calendar_count": 1, - "computed_at": "2026-08-11T18:13:30.441Z" + "finish_boundary_convention": "exclusive", + "finish_boundary_note": "ef_date / lf_date are EXCLUSIVE (the opening of the first working day AFTER the last day worked). P6 prints the LAST WORKED DAY. Both name the same instant; ef_last_worked_date and lf_last_worked_date carry the inclusive form for reports.", + "relationship_lag_calendar": "successor", + "float_type": "FT_FF", + "computed_at": "2026-09-16T02:15:31.680Z" } } diff --git a/validation/p6-comparison/cases/12-snet-fnlt/README.md b/validation/p6-comparison/cases/12-snet-fnlt/README.md index 6b5c5f5..4bf8589 100644 --- a/validation/p6-comparison/cases/12-snet-fnlt/README.md +++ b/validation/p6-comparison/cases/12-snet-fnlt/README.md @@ -16,7 +16,7 @@ A has SNET = 2026-01-20. A.ES is pinned forward to Jan 20. A duration 5 wd → A 4. F9 — A.ES forced to Jan 20; B.LF pinned to Feb 13. 5. Capture and compare. -## Engine output (produced by engine v2.9.38) +## Engine output (produced by engine v2.9.44) Project finish: `2026-02-03` diff --git a/validation/p6-comparison/cases/12-snet-fnlt/engine-output.json b/validation/p6-comparison/cases/12-snet-fnlt/engine-output.json index de92a5c..d5a738c 100644 --- a/validation/p6-comparison/cases/12-snet-fnlt/engine-output.json +++ b/validation/p6-comparison/cases/12-snet-fnlt/engine-output.json @@ -14,6 +14,8 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2218, + "task_type": "", "constraint": { "type": "SNET", "date": "2026-01-20" @@ -25,10 +27,17 @@ "date": "2026-01-20" }, "_seedLF": 2225, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-20", "ef_date": "2026-01-27", + "ef_instant_date": "2026-01-27", "ls_date": "2026-01-20", "lf_date": "2026-01-27", + "ef_last_worked_date": "2026-01-26", + "lf_last_worked_date": "2026-01-26", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff": 0, @@ -49,6 +58,8 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2225, + "task_type": "", "constraint": { "type": "FNLT", "date": "2026-02-13" @@ -60,10 +71,17 @@ "lag_days": 0 }, "_seedLF": 2225, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-27", "ef_date": "2026-02-03", + "ef_instant_date": "2026-02-03", "ls_date": "2026-01-27", "lf_date": "2026-02-03", + "ef_last_worked_date": "2026-02-02", + "lf_last_worked_date": "2026-02-02", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff_signed_working_days": 0, @@ -93,13 +111,19 @@ "message": "SNET on A pushes ES from 2026-01-05 to 2026-01-20" } ], + "excluded_by_task_type": [], + "excludedByTaskType": [], "manifest": { - "engine_version": "2.9.38", + "engine_version": "2.9.44", "method_id": "computeCPM", "activity_count": 2, "relationship_count": 1, "data_date": "2026-01-05", "calendar_count": 1, - "computed_at": "2026-08-11T18:13:30.443Z" + "finish_boundary_convention": "exclusive", + "finish_boundary_note": "ef_date / lf_date are EXCLUSIVE (the opening of the first working day AFTER the last day worked). P6 prints the LAST WORKED DAY. Both name the same instant; ef_last_worked_date and lf_last_worked_date carry the inclusive form for reports.", + "relationship_lag_calendar": "successor", + "float_type": "FT_FF", + "computed_at": "2026-09-16T02:15:31.682Z" } } diff --git a/validation/p6-comparison/cases/13-alap/README.md b/validation/p6-comparison/cases/13-alap/README.md index 687ab11..d4200a4 100644 --- a/validation/p6-comparison/cases/13-alap/README.md +++ b/validation/p6-comparison/cases/13-alap/README.md @@ -16,7 +16,7 @@ A → B → C chain. C has FNLT = Feb 28. B has secondary ALAP constraint. B sho 4. C "Finish On or Before" 2026-02-28. 5. F9 — verify B slides to its latest valid position. -## Engine output (produced by engine v2.9.38) +## Engine output (produced by engine v2.9.44) Project finish: `2026-01-22` diff --git a/validation/p6-comparison/cases/13-alap/engine-output.json b/validation/p6-comparison/cases/13-alap/engine-output.json index 8b3ac83..8b19e33 100644 --- a/validation/p6-comparison/cases/13-alap/engine-output.json +++ b/validation/p6-comparison/cases/13-alap/engine-output.json @@ -14,14 +14,23 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2201, + "task_type": "", "constraint": null, "constraint2": null, "driving_predecessor": null, "_seedLF": 2213, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-05", "ef_date": "2026-01-12", + "ef_instant_date": "2026-01-10", "ls_date": "2026-01-05", "lf_date": "2026-01-12", + "ef_last_worked_date": "2026-01-09", + "lf_last_worked_date": "2026-01-09", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff": 0, @@ -42,6 +51,8 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2208, + "task_type": "", "constraint": null, "constraint2": { "type": "ALAP", @@ -53,10 +64,17 @@ "lag_days": 0 }, "_seedLF": 2213, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-12", "ef_date": "2026-01-19", + "ef_instant_date": "2026-01-17", "ls_date": "2026-01-12", "lf_date": "2026-01-19", + "ef_last_worked_date": "2026-01-16", + "lf_last_worked_date": "2026-01-16", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff": 0, @@ -77,6 +95,8 @@ "actual_start": "", "actual_finish": "", "clndr_id": "MONFRI", + "ef_instant": 2213, + "task_type": "", "constraint": { "type": "FNLT", "date": "2026-02-28" @@ -88,10 +108,17 @@ "lag_days": 0 }, "_seedLF": 2213, + "tf_finish": 0, + "tf_start": 0, "es_date": "2026-01-19", "ef_date": "2026-01-22", + "ef_instant_date": "2026-01-22", "ls_date": "2026-01-19", "lf_date": "2026-01-22", + "ef_last_worked_date": "2026-01-21", + "lf_last_worked_date": "2026-01-21", + "tf_finish_working_days": 0, + "tf_start_working_days": 0, "tf_working_days": 0, "ff_signed": 0, "ff_signed_working_days": 0, @@ -118,13 +145,19 @@ "C" ], "alerts": [], + "excluded_by_task_type": [], + "excludedByTaskType": [], "manifest": { - "engine_version": "2.9.38", + "engine_version": "2.9.44", "method_id": "computeCPM", "activity_count": 3, "relationship_count": 2, "data_date": "2026-01-05", "calendar_count": 1, - "computed_at": "2026-08-11T18:13:30.445Z" + "finish_boundary_convention": "exclusive", + "finish_boundary_note": "ef_date / lf_date are EXCLUSIVE (the opening of the first working day AFTER the last day worked). P6 prints the LAST WORKED DAY. Both name the same instant; ef_last_worked_date and lf_last_worked_date carry the inclusive form for reports.", + "relationship_lag_calendar": "successor", + "float_type": "FT_FF", + "computed_at": "2026-09-16T02:15:31.684Z" } } diff --git a/validation/p6-comparison/comparison-matrix.md b/validation/p6-comparison/comparison-matrix.md index 60747b2..d7bca53 100644 --- a/validation/p6-comparison/comparison-matrix.md +++ b/validation/p6-comparison/comparison-matrix.md @@ -7,7 +7,7 @@ Re-run the applier against the capture sheet to refresh it. | Metric | Value | |---|---| -| Engine version | `2.9.43` | +| Engine version | `2.9.44` | | Cases fully passing | **13 / 13** | | Field-level checks | 27 | | Compared against | Primavera P6 23.12, scheduled by a human operator | diff --git a/validation/p6-comparison/engine-outputs-summary.json b/validation/p6-comparison/engine-outputs-summary.json index e14ef14..4e01296 100644 --- a/validation/p6-comparison/engine-outputs-summary.json +++ b/validation/p6-comparison/engine-outputs-summary.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-11T18:13:30.446Z", - "engine_version": "2.9.38", + "generated_at": "2026-09-16T02:15:31.685Z", + "engine_version": "2.9.44", "cases": [ { "id": "01-fs-chain",