Almirah 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,167 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'set'
4
-
5
- # Lays the WorkItem network (ADR-194) on an abstract day axis for the overview
6
- # swimlane Gantt (ADR-198). It performs a deterministic forward pass over the
7
- # dependency edges and then levels each owner's lane so two work items of the
8
- # same owner never overlap. Levelling backfills: a row may be slotted into an
9
- # idle gap left between rows placed earlier, so a low-priority short row does
10
- # not queue behind the whole lane when a gap already fits it.
11
- #
12
- # Durations are a constant placeholder (3 days) while no per-row estimates
13
- # exist; `duration_for` is the single hook ADR-195 overrides to feed real
14
- # estimates without changing the scheduling logic.
15
- #
16
- # Days are 1-based. A work item starting on day `s` with duration `d` occupies
17
- # the inclusive day span `s .. s + d - 1`; its *finish* (the next free day,
18
- # returned in the ends map) is `s + d`, the earliest a successor or the same
19
- # owner's next item may start.
20
- class WorkItemScheduler
21
- # Placeholder duration for every work item until ADR-195 supplies estimates.
22
- DEFAULT_DURATION = 3
23
-
24
- def initialize(work_items, duration: DEFAULT_DURATION)
25
- @items = work_items
26
- @duration = duration
27
- @item_set = work_items.to_set
28
- end
29
-
30
- # { work_item => start_day }, day index 1-based. Empty when there are no items.
31
- def start_days
32
- schedule unless @starts
33
- @starts
34
- end
35
-
36
- # The number of day columns the chart needs: the latest finish minus one
37
- # (finishes are the exclusive next-free day). Zero when there are no items.
38
- def day_count
39
- schedule unless @ends
40
- @ends.empty? ? 0 : (@ends.values.max - 1)
41
- end
42
-
43
- # The schedule length in working days (the latest finish, day 1 being the start).
44
- def makespan
45
- day_count
46
- end
47
-
48
- # The critical chain: the row with the latest finish, traced back through its
49
- # binding predecessors (the dependency and resource hand-offs that set each
50
- # row's start), returned in start order. Empty when nothing is scheduled.
51
- def critical_chain
52
- schedule unless @starts
53
- return [] if @ends.empty?
54
-
55
- max_end = @ends.values.max
56
- node = @items.select { |wi| @ends[wi] == max_end }.min_by { |wi| [wi.record_id, wi.step] }
57
- chain = []
58
- while node
59
- chain.unshift(node)
60
- node = @binding[node]
61
- end
62
- chain
63
- end
64
-
65
- def duration_for(_work_item)
66
- @duration
67
- end
68
-
69
- private
70
-
71
- # Greedy list-scheduler. Items are processed in a deterministic priority order
72
- # (resource-free earliest start, then activity rank, record id, step) so that
73
- # every item's predecessors are placed before it. Each item starts at the
74
- # earliest day at or after its dependency finish where its owner's lane has an
75
- # idle gap wide enough for it (resource levelling with backfill).
76
- def schedule
77
- @starts = {}
78
- @ends = {}
79
- @binding = {}
80
- @owner_rows = Hash.new { |hash, owner| hash[owner] = [] }
81
- return if @items.empty?
82
-
83
- priority_order.each { |wi| place(wi) }
84
- end
85
-
86
- # Assigns one work item its start day, records the predecessor that bound that
87
- # start, then marks the span as occupied in its owner's lane.
88
- def place(work_item)
89
- preds = scoped_predecessors(work_item)
90
- dep_finish = preds.map { |p| @ends[p] || 1 }.max || 1
91
- start, lane_pred = earliest_fit(work_item.owner, dep_finish, duration_for(work_item))
92
- @starts[work_item] = start
93
- @ends[work_item] = start + duration_for(work_item)
94
- @binding[work_item] = binding_predecessor(preds, start, lane_pred)
95
- @owner_rows[work_item.owner] << work_item unless work_item.owner.empty?
96
- end
97
-
98
- # The earliest start at or after `from` where the owner's lane stays clear for
99
- # `duration` days, plus the lane row whose finish that start had to wait behind
100
- # (nil when the row starts at `from` itself). A blank owner holds no resource,
101
- # so it never serialises.
102
- def earliest_fit(owner, from, duration)
103
- return [from, nil] if owner.empty?
104
-
105
- start = from
106
- lane_pred = nil
107
- @owner_rows[owner].sort_by { |row| @starts[row] }.each do |row|
108
- break if start + duration <= @starts[row]
109
- next if @ends[row] <= start
110
-
111
- start = @ends[row]
112
- lane_pred = row
113
- end
114
- [start, lane_pred]
115
- end
116
-
117
- # The already-placed predecessor whose finish coincides with this row's start --
118
- # the dependency or same-owner hand-off the critical chain is traced back
119
- # through. nil when the row starts at the origin with no such predecessor.
120
- def binding_predecessor(preds, start, lane_pred)
121
- candidates = preds.select { |p| @ends[p] == start }
122
- candidates << lane_pred if lane_pred
123
- candidates.min_by { |c| [c.record_id, c.step] }
124
- end
125
-
126
- def priority_order
127
- memo = {}
128
- @items.sort_by { |wi| priority_key(wi, memo) }
129
- end
130
-
131
- # The deterministic scheduling priority for a row. Overridden by the critical-
132
- # chain scheduler (ADR-195) to prioritise the longest downstream duration.
133
- def priority_key(work_item, memo)
134
- [dependency_start(work_item, memo, []), work_item.activity_rank, work_item.record_id, work_item.step]
135
- end
136
-
137
- # The earliest day this item could start ignoring resource contention: 1 when
138
- # it has no predecessors, else one past the latest predecessor finish. Memoised
139
- # and cycle-guarded — a back-edge (which the DAG-by-construction network should
140
- # never have) is treated as start 1 so scheduling still completes.
141
- def dependency_start(work_item, memo, stack)
142
- return memo[work_item] if memo.key?(work_item)
143
- return 1 if stack.include?(work_item)
144
-
145
- preds = scoped_predecessors(work_item)
146
- return memo[work_item] = 1 if preds.empty?
147
-
148
- stack.push(work_item)
149
- earliest = preds.map { |p| dependency_start(p, memo, stack) + duration_for(p) }.max
150
- stack.pop
151
- memo[work_item] = earliest
152
- end
153
-
154
- # Predecessors inside this scheduler's own item set. A predecessor scheduled in
155
- # another scope (e.g. a different decision group, ADR-201) is treated as an
156
- # already-available external input: it is dropped here and imposes no finish
157
- # constraint, so the dependent simply starts at day 1 with respect to it.
158
- def scoped_predecessors(work_item)
159
- work_item.predecessor_items.select { |p| @item_set.include?(p) }
160
- end
161
-
162
- # Successors inside this scheduler's own item set (the mirror of
163
- # scoped_predecessors), used by the critical-chain priority.
164
- def scoped_successors(work_item)
165
- work_item.successor_items.select { |s| @item_set.include?(s) }
166
- end
167
- end
@@ -1,112 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'date'
4
- require 'set'
5
-
6
- # Projects the working-day planning axis (ADR-198 / ADR-195) onto real calendar
7
- # dates (ADR-205). Working day 1 is the first working date on or after the anchor;
8
- # Saturdays, Sundays, and any configured holiday are non-working — skipped when
9
- # counting working days, but still occupying calendar columns. This is a pure
10
- # projection: it never changes the schedule, the chain, or the buffer.
11
- class WorkingCalendar
12
- SATURDAY = 6
13
- SUNDAY = 0
14
- FRIDAY = 5
15
-
16
- def initialize(anchor: Date.today, holidays: [])
17
- @holidays = holidays.to_set
18
- @start = first_working_on_or_after(anchor)
19
- end
20
-
21
- # The calendar date of the n-th working day (1-based) counted from the anchor.
22
- def date_for(working_day)
23
- return @start if working_day <= 1
24
-
25
- date = @start
26
- remaining = working_day - 1
27
- while remaining.positive?
28
- date += 1
29
- remaining -= 1 if working?(date)
30
- end
31
- date
32
- end
33
-
34
- # Every calendar date from working day 1 through the working_day_count-th working
35
- # day inclusive, including the non-working dates in between. Empty for a count
36
- # below 1.
37
- def columns(working_day_count)
38
- return [] if working_day_count < 1
39
-
40
- (@start..date_for(working_day_count)).to_a
41
- end
42
-
43
- # The 0-based calendar column index of the n-th working day within columns.
44
- def column_index(working_day)
45
- (date_for(working_day) - @start).to_i
46
- end
47
-
48
- # The compact business-day axis (ADR-206): weekday dates from working day 1
49
- # through the working_day_count-th working day, excluding Saturdays and Sundays
50
- # but including weekday holidays. Empty for a count below 1.
51
- def business_columns(working_day_count)
52
- return [] if working_day_count < 1
53
-
54
- (@start..date_for(working_day_count)).reject { |date| weekend?(date) }
55
- end
56
-
57
- # The 0-based business-column index of the n-th working day: the count of
58
- # weekdays (holidays included, weekends excluded) from the anchor through it.
59
- def business_index(working_day)
60
- (@start..date_for(working_day)).count { |date| !weekend?(date) } - 1
61
- end
62
-
63
- # The first `count` business-day (weekday) dates from the anchor, holidays
64
- # included and weekends excluded — the calendar labels for an axis of `count`
65
- # columns, even when it runs past the schedule to cover authored actuals
66
- # (ADR-213). Empty for a count below 1.
67
- def business_axis(count)
68
- return [] if count < 1
69
-
70
- dates = []
71
- date = @start
72
- while dates.length < count
73
- dates << date unless weekend?(date)
74
- date += 1
75
- end
76
- dates
77
- end
78
-
79
- # The 0-based business-column index of a real calendar date relative to the
80
- # anchor (ADR-213): the count of weekdays from the anchor through the date, minus
81
- # one. A weekend date snaps to the preceding weekday's column; a date on or
82
- # before the anchor clamps to column 0. Used to place authored committed/logged
83
- # dates on the same business-day axis as the schedule.
84
- def business_column_for(date)
85
- return 0 if date <= @start
86
-
87
- (@start..date).count { |d| !weekend?(d) } - 1
88
- end
89
-
90
- def friday?(date)
91
- date.wday == FRIDAY
92
- end
93
-
94
- def working?(date)
95
- !non_working?(date)
96
- end
97
-
98
- def non_working?(date)
99
- weekend?(date) || @holidays.include?(date)
100
- end
101
-
102
- def weekend?(date)
103
- [SATURDAY, SUNDAY].include?(date.wday)
104
- end
105
-
106
- private
107
-
108
- def first_working_on_or_after(date)
109
- date += 1 while non_working?(date)
110
- date
111
- end
112
- end