Skip to content

Commit ece82a9

Browse files
committed
fix: backport tf_working_days to Python + crossval (audit R9 LOW)
JS computed tf_working_days (TF in working days on the activity's own calendar, signed for negative-float). Python emitted only tf (calendar days). An expert quoting JS's tf=13 against a MonFri-cal activity would be impeached when P6 / Python showed 10. Backported: python_reference/cpm.py: new _count_work_days_between() helper mirroring JS cpm-engine.js:814. Reuses the v2.9.27 holiday-Set cache for performance. python_reference/cpm.py: per-node tf_working_days populated in the post-pass loop (lines 1326-1335), mirroring JS:2270. Crossval harness extended: - PY_HARNESS extractor now emits node.tf_working_days - JS extractor now emits node.tf_working_days - compareFixture compares the two bit-identically when both sides emit the field Crossval went from 444 → 545 checks (+101 tf_working_days comparisons across all 43 fixtures); all bit-identical. Tests: 1064/0 unit, 43/43 crossval (545/545 checks).
1 parent cb8fc65 commit ece82a9

2 files changed

Lines changed: 70 additions & 0 deletions

File tree

cpm-engine.crossval.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ result_json = {
128128
'tf': n['tf'],
129129
'es_date': n['es_date'], 'ef_date': n['ef_date'],
130130
'ls_date': n['ls_date'], 'lf_date': n['lf_date'],
131+
# v2.9.27 — audit R9 LOW PAIRED FIX. Backported field.
132+
'tf_working_days': n.get('tf_working_days'),
131133
}
132134
for c, n in result['nodes'].items()
133135
}
@@ -173,6 +175,8 @@ function runJS(payload) {
173175
tf: n.tf,
174176
es_date: n.es_date, ef_date: n.ef_date,
175177
ls_date: n.ls_date, lf_date: n.lf_date,
178+
// v2.9.27 — audit R9 LOW PAIRED FIX. Backported field.
179+
tf_working_days: n.tf_working_days,
176180
};
177181
}
178182
// Severity-level alert breakdown for crossval parity (Round 6).
@@ -285,6 +289,12 @@ function compareFixture(name, payload, opts) {
285289
eq('node ' + code + '.dates',
286290
{ es_date: a.es_date, ef_date: a.ef_date, ls_date: a.ls_date, lf_date: a.lf_date },
287291
{ es_date: b.es_date, ef_date: b.ef_date, ls_date: b.ls_date, lf_date: b.lf_date });
292+
// v2.9.27 — audit R9 LOW. tf_working_days now backported to Python;
293+
// compare bit-identically. Only compare if BOTH sides emit the field.
294+
if (a.tf_working_days !== undefined && b.tf_working_days !== undefined) {
295+
eq('node ' + code + '.tf_working_days',
296+
a.tf_working_days, b.tf_working_days);
297+
}
288298
}
289299
if (fails === 0) fixturesPassed += 1; else fixturesFailed += 1;
290300
}

python_reference/cpm.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,56 @@ def _is_clean_monfri(work_days, holidays):
361361
return m == 62 # 0b00111110 = Mon..Fri bits set
362362

363363

364+
def _count_work_days_between(from_num, to_num, calendar_info=None):
365+
"""Count working days between two date-offsets on the given calendar.
366+
367+
v2.9.27 — audit R9 LOW PAIRED FIX. Mirrors JS _countWorkDaysBetween
368+
at cpm-engine.js:814. Returns a signed integer count so callers
369+
(tf_working_days, ff_working_days) can preserve negative-float
370+
forensic signal on over-constrained networks.
371+
372+
from_num / to_num are integer day-offsets from the engine epoch
373+
(2020-01-01). Without a calendar, returns the calendar-day count
374+
(signed). With a calendar, walks day-by-day counting only working
375+
days on that calendar.
376+
"""
377+
if not isinstance(from_num, int) and not isinstance(from_num, float):
378+
return 0
379+
if not isinstance(to_num, int) and not isinstance(to_num, float):
380+
return 0
381+
from math import isfinite
382+
if not isfinite(from_num) or not isfinite(to_num):
383+
return 0
384+
if to_num == from_num:
385+
return 0
386+
if to_num < from_num:
387+
return -_count_work_days_between(to_num, from_num, calendar_info)
388+
if calendar_info is None:
389+
return _round_half_up(to_num - from_num)
390+
work_days = calendar_info.get('work_days') or [1, 2, 3, 4, 5]
391+
# Reuse the cached holiday Set added in v2.9.27.
392+
_hs = calendar_info.get('_holidays_set_cache')
393+
if _hs is None:
394+
_hs = set(calendar_info.get('holidays') or [])
395+
try:
396+
calendar_info['_holidays_set_cache'] = _hs
397+
except TypeError:
398+
pass
399+
holidays = _hs
400+
if not work_days:
401+
return 0
402+
# Walk day-by-day from from_num+1 to to_num inclusive.
403+
n = 0
404+
cur = _round_half_up(from_num)
405+
end = _round_half_up(to_num)
406+
while cur < end:
407+
cur += 1
408+
dt = date.fromordinal(cur + _epoch_ordinal())
409+
if _is_work_day(dt, work_days, holidays):
410+
n += 1
411+
return n
412+
413+
364414
def add_work_days(start_date, n_workdays, calendar_info=None):
365415
"""Advance start_date by n_workdays working days on the given calendar."""
366416
if n_workdays is None:
@@ -1269,6 +1319,16 @@ def _cal_for(node):
12691319
n['ef_date'] = num_to_date(n['ef'])
12701320
n['ls_date'] = num_to_date(n['ls'])
12711321
n['lf_date'] = num_to_date(n['lf'])
1322+
# v2.9.27 — audit R9 LOW PAIRED FIX. tf_working_days companion to
1323+
# tf (calendar days). P6 reports float in working days on the
1324+
# activity's own calendar; an expert quoting tf=13 against a
1325+
# MonFri-cal activity will be impeached if P6 shows 10. Mirrors
1326+
# JS cpm-engine.js:2270.
1327+
if n['is_complete']:
1328+
n['tf_working_days'] = 0
1329+
else:
1330+
n_cal = cal_map.get(n.get('clndr_id', '')) if n.get('clndr_id') else None
1331+
n['tf_working_days'] = _count_work_days_between(n['ef'], n['lf'], n_cal)
12721332

12731333
critical = {c for c, n in nodes.items() if n['tf'] <= 0.0 and not n['is_complete']}
12741334

0 commit comments

Comments
 (0)