jekyll-relationships 0.1.0.alpha → 0.2.0.alpha

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jekyll-relationships/configuration/debug_setting.rb +72 -12
  3. data/lib/jekyll-relationships/configuration/defaults.rb +3 -1
  4. data/lib/jekyll-relationships/configuration/frontmatter.rb +28 -1
  5. data/lib/jekyll-relationships/configuration/parser.rb +20 -3
  6. data/lib/jekyll-relationships/configuration/prune_rule_settings.rb +33 -5
  7. data/lib/jekyll-relationships/configuration/tree_frontmatter.rb +8 -2
  8. data/lib/jekyll-relationships/configuration.rb +43 -8
  9. data/lib/jekyll-relationships/debug_logger.rb +94 -3
  10. data/lib/jekyll-relationships/definitions/normal_relationship.rb +13 -2
  11. data/lib/jekyll-relationships/definitions/prune_rule.rb +19 -3
  12. data/lib/jekyll-relationships/definitions/tree_relationship.rb +8 -2
  13. data/lib/jekyll-relationships/documents/registry.rb +182 -33
  14. data/lib/jekyll-relationships/engine/normal_seed.rb +222 -0
  15. data/lib/jekyll-relationships/engine/persisted_links.rb +365 -0
  16. data/lib/jekyll-relationships/engine/raw_path_state.rb +46 -116
  17. data/lib/jekyll-relationships/engine/relationship_state.rb +67 -56
  18. data/lib/jekyll-relationships/engine/session.rb +159 -19
  19. data/lib/jekyll-relationships/engine/write_back.rb +3 -42
  20. data/lib/jekyll-relationships/engine.rb +168 -46
  21. data/lib/jekyll-relationships/pruning/rule_pruner.rb +7 -7
  22. data/lib/jekyll-relationships/pruning/tree_phase.rb +128 -124
  23. data/lib/jekyll-relationships/pruning/tree_provenance.rb +1 -1
  24. data/lib/jekyll-relationships/references/accumulator.rb +87 -9
  25. data/lib/jekyll-relationships/references/template.rb +46 -11
  26. data/lib/jekyll-relationships/resolvers/base.rb +59 -2
  27. data/lib/jekyll-relationships/support/frontmatter_matcher.rb +76 -0
  28. data/lib/jekyll-relationships/support/placeholders.rb +1 -0
  29. data/lib/jekyll-relationships/trees/edge_builder.rb +43 -11
  30. data/lib/jekyll-relationships/trees/graph.rb +135 -17
  31. data/lib/jekyll-relationships/trees/root_distances.rb +44 -0
  32. data/lib/jekyll-relationships/version.rb +1 -1
  33. data/lib/jekyll-relationships.rb +1 -0
  34. data/readme.md +185 -26
  35. metadata +6 -2
@@ -3,6 +3,8 @@
3
3
  require 'jekyll-relationships/engine/session'
4
4
  require 'jekyll-relationships/engine/raw_path_state'
5
5
  require 'jekyll-relationships/engine/relationship_state'
6
+ require 'jekyll-relationships/engine/normal_seed'
7
+ require 'jekyll-relationships/engine/persisted_links'
6
8
  require 'jekyll-relationships/engine/write_back'
7
9
  require 'jekyll-relationships/trees/graph'
8
10
 
@@ -27,7 +29,9 @@ class Engine
27
29
  @configuration = Configuration.new(@site.config)
28
30
  return unless @configuration.enabled?
29
31
 
30
- @debug_logger = DebugLogger.new
32
+ @debug_logger = DebugLogger.new(
33
+ reference_key_property: @configuration.reference_template.key_property
34
+ )
31
35
  @run_logger = RunLogger.new
32
36
  @registry = Documents::Registry.new(site: @site, collections: @configuration.collections)
33
37
  @data_path = Jekyll::Plugins::Relationships::Support::DataPath.new
@@ -39,33 +43,33 @@ class Engine
39
43
  return if @configuration.collections.empty?
40
44
 
41
45
  @registry.validate_collections!
42
- @registry.validate_primary_paths!(primary_paths: @configuration.primary_paths)
43
- original_tree_graph = build_tree_graph(active_document_ids: all_active_document_ids)
44
- tree_provenance = Pruning::TreeProvenance.new(tree_graph: original_tree_graph)
45
- prune_result = prune_result_for(
46
- tree_provenance: tree_provenance,
47
- original_tree_graph: original_tree_graph
48
- )
49
- final_session = Session.new(
50
- engine: self,
51
- active_document_ids: prune_result.fetch(:active_document_ids),
52
- tree_graph: prune_result.fetch(:tree_graph)
46
+ @registry.validate_identity_schemes!(identity_schemes: @configuration.identity_schemes)
47
+ initial_active_document_ids = all_active_document_ids
48
+ initial_tree_seed_graph = build_tree_graph(active_document_ids: initial_active_document_ids)
49
+ normal_seed = NormalSeed.new(engine: self, active_document_ids: initial_active_document_ids)
50
+ persisted_links = PersistedLinks.new(engine: self)
51
+ final_result = final_result_for(
52
+ initial_active_document_ids: initial_active_document_ids,
53
+ initial_tree_seed_graph: initial_tree_seed_graph,
54
+ normal_seed: normal_seed,
55
+ persisted_links: persisted_links
53
56
  )
54
- final_session.resolve_all_relationships!
57
+ final_session = final_result.fetch(:session)
58
+ final_tree_graph = final_result.fetch(:tree_graph)
55
59
  final_normal_graph = Pruning::NormalGraph.new(session: final_session)
56
60
 
57
61
  log_relationship_summary(
58
62
  session: final_session,
59
63
  normal_graph: final_normal_graph,
60
- tree_graph: prune_result.fetch(:tree_graph)
64
+ tree_graph: final_tree_graph
61
65
  )
62
66
  @run_logger.pruning_summary(
63
- removed_documents_by_collection: removed_documents_by_collection(prune_result.fetch(:removed_documents))
67
+ removed_documents_by_collection: removed_documents_by_collection(final_result.fetch(:removed_documents))
64
68
  ) if @configuration.pruning_enabled?
65
69
 
66
70
  final_session.write_back!
67
- prune_result.fetch(:tree_graph).write_back!
68
- remove_inactive_documents!(active_document_ids: prune_result.fetch(:active_document_ids))
71
+ final_tree_graph.write_back!
72
+ remove_inactive_documents!(active_document_ids: final_result.fetch(:active_document_ids))
69
73
  @run_logger.finish!
70
74
  end
71
75
 
@@ -92,31 +96,55 @@ class Engine
92
96
  tree_graph
93
97
  end
94
98
 
95
- # Runs the configured prune rounds and returns the final active document set.
96
- def prune_result_for(tree_provenance:, original_tree_graph:)
97
- current_active_document_ids = all_active_document_ids
99
+ # Returns the final active graphs, either directly or via prune iteration.
100
+ def final_result_for(initial_active_document_ids:, initial_tree_seed_graph:, normal_seed:, persisted_links:)
101
+ return resolved_result_for(
102
+ active_document_ids: initial_active_document_ids,
103
+ tree_graph: initial_tree_seed_graph.deep_dup,
104
+ normal_seed: normal_seed,
105
+ persisted_links: persisted_links,
106
+ removed_documents: []
107
+ ) unless @configuration.pruning_enabled?
108
+
109
+ prune_result_for(
110
+ initial_active_document_ids: initial_active_document_ids,
111
+ initial_tree_seed_graph: initial_tree_seed_graph,
112
+ normal_seed: normal_seed,
113
+ persisted_links: persisted_links
114
+ )
115
+ end
116
+
117
+ # Runs the configured prune rounds and returns the final active graphs.
118
+ def prune_result_for(initial_active_document_ids:, initial_tree_seed_graph:, normal_seed:, persisted_links:)
119
+ tree_phase = Pruning::TreePhase.new(
120
+ engine: self,
121
+ provenance: Pruning::TreeProvenance.new(tree_graph: initial_tree_seed_graph)
122
+ )
123
+ current_active_document_ids = initial_active_document_ids.dup
124
+ current_tree_seed_graph = initial_tree_seed_graph
98
125
  removed_documents = {}
99
- current_tree_graph = original_tree_graph
100
- return {
101
- active_document_ids: current_active_document_ids,
102
- removed_documents: [],
103
- tree_graph: current_tree_graph
104
- } unless @configuration.pruning_enabled?
105
126
 
106
- tree_phase = Pruning::TreePhase.new(engine: self, provenance: tree_provenance)
107
127
  @configuration.prune_settings.prune_rounds.times do
108
- tree_phase_result = tree_phase.process(active_document_ids: current_active_document_ids)
128
+ tree_phase_result = tree_phase.process(seed_graph: current_tree_seed_graph)
129
+ tree_seed_update = apply_removed_documents_to_seeds!(
130
+ tree_phase: tree_phase,
131
+ tree_seed_graph: current_tree_seed_graph,
132
+ normal_seed: normal_seed,
133
+ persisted_links: persisted_links,
134
+ current_active_document_ids: current_active_document_ids,
135
+ documents: tree_phase_result.fetch(:removed_documents)
136
+ )
137
+ current_tree_seed_graph = tree_seed_update.fetch(:tree_seed_graph)
109
138
  register_removed_documents!(
110
139
  removed_documents: removed_documents,
111
- documents: tree_phase_result.fetch(:removed_documents)
140
+ documents: tree_seed_update.fetch(:removed_documents)
112
141
  )
113
- current_active_document_ids = tree_phase_result.fetch(:active_document_ids)
114
- current_tree_graph = tree_phase_result.fetch(:tree_graph)
115
142
 
116
- session = Session.new(
117
- engine: self,
143
+ session = build_session(
118
144
  active_document_ids: current_active_document_ids,
119
- tree_graph: current_tree_graph
145
+ tree_graph: tree_phase_result.fetch(:graph),
146
+ normal_seed: normal_seed,
147
+ persisted_links: persisted_links
120
148
  )
121
149
  session.resolve_all_relationships!
122
150
 
@@ -124,35 +152,122 @@ class Engine
124
152
  graph: Pruning::NormalGraph.new(session: session),
125
153
  rules: @configuration.normal_prune_rules
126
154
  ).prune!
127
- if normal_removed_documents.empty?
155
+ if normal_removed_documents.empty? && !tree_seed_changes_require_iteration?(tree_seed_update)
128
156
  return {
129
157
  active_document_ids: current_active_document_ids,
130
158
  removed_documents: removed_documents.values,
131
- tree_graph: current_tree_graph
159
+ tree_graph: tree_phase_result.fetch(:graph),
160
+ session: session
132
161
  }
133
162
  end
134
163
 
135
- register_removed_documents!(
136
- removed_documents: removed_documents,
164
+ normal_seed_update = apply_removed_documents_to_seeds!(
165
+ tree_phase: tree_phase,
166
+ tree_seed_graph: current_tree_seed_graph,
167
+ normal_seed: normal_seed,
168
+ persisted_links: persisted_links,
169
+ current_active_document_ids: current_active_document_ids,
137
170
  documents: normal_removed_documents
138
171
  )
139
- current_active_document_ids -= normal_removed_documents.map(&:object_id)
140
- post_normal_tree_phase = tree_phase.process(active_document_ids: current_active_document_ids)
172
+ current_tree_seed_graph = normal_seed_update.fetch(:tree_seed_graph)
141
173
  register_removed_documents!(
142
174
  removed_documents: removed_documents,
143
- documents: post_normal_tree_phase.fetch(:removed_documents)
175
+ documents: normal_seed_update.fetch(:removed_documents)
144
176
  )
145
- current_active_document_ids = post_normal_tree_phase.fetch(:active_document_ids)
146
- current_tree_graph = post_normal_tree_phase.fetch(:tree_graph)
147
177
  end
148
178
 
149
- {
179
+ resolved_result_for(
150
180
  active_document_ids: current_active_document_ids,
151
- removed_documents: removed_documents.values,
152
- tree_graph: current_tree_graph
181
+ tree_graph: current_tree_seed_graph.deep_dup,
182
+ normal_seed: normal_seed,
183
+ persisted_links: persisted_links,
184
+ removed_documents: removed_documents.values
185
+ )
186
+ end
187
+
188
+ # Builds one final resolved session for the currently surviving seeds.
189
+ def resolved_result_for(active_document_ids:, tree_graph:, normal_seed:, persisted_links:, removed_documents:)
190
+ session = build_session(
191
+ active_document_ids: active_document_ids,
192
+ tree_graph: tree_graph,
193
+ normal_seed: normal_seed,
194
+ persisted_links: persisted_links
195
+ )
196
+ session.resolve_all_relationships!
197
+ {
198
+ active_document_ids: active_document_ids,
199
+ removed_documents: removed_documents,
200
+ tree_graph: tree_graph,
201
+ session: session
202
+ }
203
+ end
204
+
205
+ # Builds one normal-resolution session for one active graph snapshot.
206
+ def build_session(active_document_ids:, tree_graph:, normal_seed:, persisted_links:)
207
+ Session.new(
208
+ engine: self,
209
+ active_document_ids: active_document_ids,
210
+ tree_graph: tree_graph,
211
+ normal_seed: normal_seed,
212
+ persisted_links: persisted_links
213
+ )
214
+ end
215
+
216
+ # Applies removals to both seeds and returns the updated tree seed graph.
217
+ def apply_removed_documents_to_seeds!(tree_phase:, tree_seed_graph:, normal_seed:, persisted_links:, current_active_document_ids:, documents:)
218
+ requested_removed_documents = unique_documents(documents)
219
+ remove_from_normal_side!(
220
+ normal_seed: normal_seed,
221
+ persisted_links: persisted_links,
222
+ current_active_document_ids: current_active_document_ids,
223
+ documents: requested_removed_documents
224
+ )
225
+ tree_seed_result = tree_phase.apply_seed_removals(
226
+ seed_graph: tree_seed_graph,
227
+ documents: requested_removed_documents
228
+ )
229
+ extra_tree_removed_documents = unique_documents(
230
+ tree_seed_result.fetch(:removed_documents).reject do |document|
231
+ requested_removed_documents.any? { |requested_document| requested_document.object_id == document.object_id }
232
+ end
233
+ )
234
+ remove_from_normal_side!(
235
+ normal_seed: normal_seed,
236
+ persisted_links: persisted_links,
237
+ current_active_document_ids: current_active_document_ids,
238
+ documents: extra_tree_removed_documents
239
+ )
240
+
241
+ {
242
+ tree_seed_graph: tree_seed_result.fetch(:seed_graph),
243
+ removed_documents: unique_documents(requested_removed_documents + tree_seed_result.fetch(:removed_documents)),
244
+ seed_changed: tree_seed_result.fetch(:seed_changed)
153
245
  }
154
246
  end
155
247
 
248
+ # Removes documents from the normal seed, persisted overlay, and active set.
249
+ def remove_from_normal_side!(normal_seed:, persisted_links:, current_active_document_ids:, documents:)
250
+ documents = unique_documents(documents)
251
+ return if documents.empty?
252
+
253
+ normal_seed.remove_documents!(documents)
254
+ persisted_links.remove_documents!(documents)
255
+ documents.each do |document|
256
+ current_active_document_ids.delete(document.object_id)
257
+ end
258
+ end
259
+
260
+ # Returns true when tree-seed changes should schedule another outer round.
261
+ #
262
+ # Tree resolvers do not yet exist, so tree pruning can settle and flow
263
+ # directly into the same round's normal phase. When tree resolvers are added
264
+ # later, changing the seed here should trigger another full outer rebuild.
265
+ def tree_seed_changes_require_iteration?(tree_seed_update)
266
+ return false unless tree_seed_update.fetch(:seed_changed)
267
+
268
+ false
269
+ end
270
+
156
271
  # Groups removed documents by collection label for summary logging.
157
272
  def removed_documents_by_collection(documents)
158
273
  Array(documents).each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |document, grouped_documents|
@@ -167,6 +282,13 @@ class Engine
167
282
  end
168
283
  end
169
284
 
285
+ # Returns one document array with later duplicates removed by object id.
286
+ def unique_documents(documents)
287
+ Array(documents).each_with_object({}) do |document, unique_documents|
288
+ unique_documents[document.object_id] ||= document
289
+ end.values
290
+ end
291
+
170
292
  # Logs the configured relationship summary for the final active graph.
171
293
  def log_relationship_summary(session:, normal_graph:, tree_graph:)
172
294
  tree_graph_view = Pruning::TreePhase::TreeGraphView.new(tree_graph: tree_graph)
@@ -6,12 +6,11 @@ module Plugins
6
6
  module Relationships
7
7
  module Pruning
8
8
 
9
- # Applies minimum-count prune rules to a mutable graph view.
9
+ # Applies configured prune-rule predicates to a mutable graph view.
10
10
  #
11
11
  # The graph view may represent either resolved normal relationships or the
12
- # current tree adjacency. Pruning is recursive within the supplied graph:
13
- # removing one document can reduce the neighbour counts of others, so the pruner
14
- # keeps deleting documents until every rule is satisfied.
12
+ # current tree adjacency. Pruning is recursive within the supplied graph because
13
+ # removing one document can make relationship predicates select others.
15
14
  class RulePruner
16
15
  # Builds one pruner over one mutable graph view and one ordered rule list.
17
16
  def initialize(graph:, rules:, subject_documents_resolver: nil)
@@ -20,7 +19,7 @@ class RulePruner
20
19
  @subject_documents_resolver = subject_documents_resolver
21
20
  end
22
21
 
23
- # Removes documents until all configured minimums are satisfied.
22
+ # Removes documents until no configured rule selects another candidate.
24
23
  def prune!
25
24
  removed_documents = []
26
25
 
@@ -40,12 +39,13 @@ class RulePruner
40
39
 
41
40
  private
42
41
 
43
- # Finds every document that currently violates at least one prune rule.
42
+ # Finds every document selected by at least one prune rule.
44
43
  def prune_candidates
45
44
  @rules.each_with_object({}) do |rule, candidates|
46
45
  subject_documents_for(rule).each do |document|
47
46
  next if candidates.key?(document.object_id)
48
- next unless rule.neighbour_documents(graph: @graph, document: document).length < rule.min
47
+ next unless rule.frontmatter_selected?(document)
48
+ next unless rule.relationship_selected?(graph: @graph, document: document)
49
49
 
50
50
  candidates[document.object_id] = document
51
51
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'jekyll-relationships/trees/root_distances'
4
+
3
5
  module Jekyll
4
6
  module Plugins
5
7
 
@@ -21,6 +23,7 @@ class TreePhase
21
23
  @documents_by_collection = Hash.new { |hash, key| hash[key] = {} }
22
24
  @parents = Hash.new { |hash, key| hash[key] = {} }
23
25
  @children = Hash.new { |hash, key| hash[key] = {} }
26
+ @root_distance_cache = nil
24
27
  register_documents!
25
28
  register_edges!
26
29
  end
@@ -30,6 +33,32 @@ class TreePhase
30
33
  @documents_by_collection[collection].values
31
34
  end
32
35
 
36
+ # Returns every currently active tree document.
37
+ def tree_documents
38
+ @documents_by_collection.values.flat_map(&:values)
39
+ end
40
+
41
+ # Returns the direct parent documents for one active document.
42
+ def parent_documents_for(document)
43
+ return [] unless active_document?(document)
44
+
45
+ @parents[document].keys
46
+ end
47
+
48
+ # Returns the direct child documents for one active document.
49
+ def child_documents_for(document)
50
+ return [] unless active_document?(document)
51
+
52
+ @children[document].keys
53
+ end
54
+
55
+ # Returns the shortest current distance from one document to any root.
56
+ def root_distance_for(document)
57
+ return nil unless active_document?(document)
58
+
59
+ root_distances_for_graph[document.object_id]
60
+ end
61
+
33
62
  # Returns the relevant neighbour documents for one configured tree member.
34
63
  def neighbour_documents_for_member(member:, document:, inverse:)
35
64
  return inverse_neighbours_for(member: member, document: document) if inverse
@@ -49,6 +78,7 @@ class TreePhase
49
78
  end
50
79
  @children.delete(document)
51
80
  @parents.delete(document)
81
+ clear_root_distance_cache
52
82
  true
53
83
  end
54
84
 
@@ -116,6 +146,21 @@ class TreePhase
116
146
  combined[document.object_id] ||= document
117
147
  end.values
118
148
  end
149
+
150
+ # Returns true when one document is still active in the mutable view.
151
+ def active_document?(document)
152
+ @documents_by_collection[document.collection.label].key?(document.object_id)
153
+ end
154
+
155
+ # Calculates the current root distances and caches them until the view changes.
156
+ def root_distances_for_graph
157
+ @root_distance_cache ||= Trees::RootDistances.for(graph: self)
158
+ end
159
+
160
+ # Clears the cached root distances after a structural tree change.
161
+ def clear_root_distance_cache
162
+ @root_distance_cache = nil
163
+ end
119
164
  end
120
165
 
121
166
  # Builds one tree phase helper for one engine and one immutable provenance set.
@@ -125,134 +170,88 @@ class TreePhase
125
170
  @provenance = provenance
126
171
  end
127
172
 
128
- # Builds the pruned tree graph for one active document set.
129
- def process(active_document_ids:)
130
- current_active_document_ids = active_document_ids.dup
173
+ # Resolves tree pruning against one concrete tree seed graph.
174
+ def process(seed_graph:)
175
+ current_tree_graph = seed_graph.deep_dup
131
176
  removed_documents = []
132
- @current_active_document_ids = active_document_lookup(current_active_document_ids)
133
- orphan_result = stabilise_orphans!(
134
- current_active_document_ids: current_active_document_ids,
135
- removed_documents: removed_documents
136
- )
137
- current_tree_graph = orphan_result.fetch(:graph)
177
+ stabilise_orphans!(graph: current_tree_graph, removed_documents: removed_documents)
138
178
  return {
139
- tree_graph: current_tree_graph,
140
- active_document_ids: current_active_document_ids,
141
- removed_documents: removed_documents
179
+ graph: current_tree_graph,
180
+ removed_documents: unique_documents(removed_documents)
142
181
  } if @configuration.tree_prune_rules.empty?
143
182
 
144
- eligible_document_ids_by_rule = eligible_document_ids_by_rule(graph: current_tree_graph)
145
-
146
183
  loop do
147
- pruned_documents = prune_tree_rules(
148
- graph: current_tree_graph,
149
- eligible_document_ids_by_rule: eligible_document_ids_by_rule
150
- )
184
+ pruned_documents = prune_tree_rules(graph: current_tree_graph)
151
185
  if pruned_documents.empty?
152
186
  return {
153
- tree_graph: current_tree_graph,
154
- active_document_ids: current_active_document_ids,
155
- removed_documents: removed_documents
187
+ graph: current_tree_graph,
188
+ removed_documents: unique_documents(removed_documents)
156
189
  }
157
190
  end
158
191
 
159
- remove_documents!(
160
- current_active_document_ids: current_active_document_ids,
161
- documents: pruned_documents
162
- )
192
+ current_tree_graph.remove_documents!(pruned_documents)
163
193
  removed_documents.concat(pruned_documents)
164
- current_tree_graph = stabilise_orphans!(
165
- current_active_document_ids: current_active_document_ids,
166
- removed_documents: removed_documents
167
- ).fetch(:graph)
194
+ stabilise_orphans!(graph: current_tree_graph, removed_documents: removed_documents)
195
+ end
196
+ end
197
+
198
+ # Applies committed removals to one tree seed graph and repairs the tree.
199
+ #
200
+ # The tree seed only changes because documents have permanently died. Resolver
201
+ # output never flows back into the seed, but structural repair such as orphan
202
+ # reattachment must be reflected so the next round starts from the right tree.
203
+ def apply_seed_removals(seed_graph:, documents:)
204
+ documents_to_remove = Array(documents).select do |document|
205
+ seed_graph.participating?(document) && seed_graph.active_document?(document)
206
+ end
207
+ return {
208
+ seed_graph: seed_graph,
209
+ removed_documents: [],
210
+ seed_changed: false
211
+ } if documents_to_remove.empty?
212
+
213
+ next_seed_graph = seed_graph.deep_dup
214
+ removed_documents = documents_to_remove.select do |document|
215
+ next_seed_graph.remove_document!(document)
168
216
  end
217
+ stabilise_orphans!(graph: next_seed_graph, removed_documents: removed_documents)
218
+
219
+ {
220
+ seed_graph: next_seed_graph,
221
+ removed_documents: unique_documents(removed_documents),
222
+ seed_changed: removed_documents.any?
223
+ }
169
224
  end
170
225
 
171
226
  private
172
227
 
173
- # Rebuilds the tree graph until orphan handling no longer removes documents.
174
- def stabilise_orphans!(current_active_document_ids:, removed_documents:)
228
+ # Removes or reattaches orphans until the graph stabilises.
229
+ def stabilise_orphans!(graph:, removed_documents:)
175
230
  loop do
176
- @current_active_document_ids = active_document_lookup(current_active_document_ids)
177
- base_graph = build_tree_graph(active_document_ids: current_active_document_ids)
178
- orphan_result = apply_orphan_policy(graph: base_graph)
231
+ orphan_result = apply_orphan_policy(graph: graph)
179
232
  orphaned_documents = orphan_result.fetch(:removed_documents)
180
- return {
181
- graph: orphan_result.fetch(:graph)
182
- } if orphaned_documents.empty?
233
+ return graph if orphaned_documents.empty?
183
234
 
184
- remove_documents!(
185
- current_active_document_ids: current_active_document_ids,
186
- documents: orphaned_documents
187
- )
235
+ graph.remove_documents!(orphaned_documents)
188
236
  removed_documents.concat(orphaned_documents)
189
237
  end
190
238
  end
191
239
 
192
- # Builds one fresh tree graph for the current active document set.
193
- def build_tree_graph(active_document_ids:)
194
- tree_graph = Trees::Graph.new(
195
- site: @engine.site,
196
- configuration: @configuration,
197
- registry: @engine.registry,
198
- data_path: @engine.data_path,
199
- debug_logger: @engine.debug_logger,
200
- active_document_ids: active_document_ids
201
- )
202
- tree_graph.build!
203
- tree_graph
204
- end
205
-
206
- # Builds one stable lookup of which documents each tree rule may prune.
207
- def eligible_document_ids_by_rule(graph:)
208
- root_distances = root_distances_for(graph)
209
- @configuration.tree_prune_rules.each_with_object({}) do |rule, lookup|
210
- lookup[rule.object_id] = graph.documents_for(rule.subject_collection).each_with_object({}) do |document, eligible_documents|
211
- next unless depth_selected?(depth: rule.depth, root_distance: root_distances[document.object_id])
212
-
213
- eligible_documents[document.object_id] = true
214
- end
215
- end
216
- end
217
-
218
- # Runs the configured tree prune rules against the current graph while only
219
- # allowing the documents selected by the initial depth snapshot to be pruned.
220
- def prune_tree_rules(graph:, eligible_document_ids_by_rule:)
240
+ # Runs the configured tree prune rules against the current graph, re-reading
241
+ # depth from the mutable graph view each time candidates are evaluated.
242
+ def prune_tree_rules(graph:)
221
243
  RulePruner.new(
222
244
  graph: TreeGraphView.new(tree_graph: graph),
223
245
  rules: @configuration.tree_prune_rules,
224
246
  subject_documents_resolver: lambda do |rule, mutable_graph|
225
- eligible_document_ids = eligible_document_ids_by_rule.fetch(rule.object_id)
226
247
  mutable_graph.documents_for(rule.subject_collection).select do |document|
227
- eligible_document_ids.key?(document.object_id)
248
+ depth = rule.depth
249
+ depth.nil? || depth_selected?(depth: depth, root_distance: mutable_graph.root_distance_for(document))
228
250
  end
229
251
  end
230
252
  ).prune!
231
253
  end
232
254
 
233
- # Calculates the shortest distance from every node to any root in the current tree.
234
- def root_distances_for(graph)
235
- root_distances = {}
236
- queue = graph.tree_documents.select do |document|
237
- graph.parent_documents_for(document).empty?
238
- end.sort_by(&:relative_path).map do |root_document|
239
- [root_document, 0]
240
- end
241
-
242
- until queue.empty?
243
- document, distance = queue.shift
244
- existing_distance = root_distances[document.object_id]
245
- next if !existing_distance.nil? && existing_distance <= distance
246
-
247
- root_distances[document.object_id] = distance
248
- graph.child_documents_for(document).each do |child_document|
249
- queue << [child_document, distance + 1]
250
- end
251
- end
252
-
253
- root_distances
254
- end
255
-
256
255
  # Returns true when one root distance is eligible under one configured depth.
257
256
  def depth_selected?(depth:, root_distance:)
258
257
  return false if root_distance.nil?
@@ -264,21 +263,6 @@ class TreePhase
264
263
  end
265
264
  end
266
265
 
267
- # Removes many documents from the current active set in place.
268
- def remove_documents!(current_active_document_ids:, documents:)
269
- Array(documents).each do |document|
270
- current_active_document_ids.delete(document.object_id)
271
- @current_active_document_ids.delete(document.object_id)
272
- end
273
- end
274
-
275
- # Builds one fast active-document lookup for the current build state.
276
- def active_document_lookup(active_document_ids)
277
- active_document_ids.each_with_object({}) do |document_id, active_ids|
278
- active_ids[document_id] = true
279
- end
280
- end
281
-
282
266
  # Applies the configured orphan policy to one current tree graph.
283
267
  def apply_orphan_policy(graph:)
284
268
  removed_documents = []
@@ -307,30 +291,31 @@ class TreePhase
307
291
  end.sort_by(&:relative_path)
308
292
  end
309
293
 
310
- # Attempts to reconnect one orphan to its surviving original grandparents.
294
+ # Attempts to reconnect one orphan to its nearest surviving original ancestors.
311
295
  def reattach_orphan!(graph:, document:)
312
- grandparent_candidates_for(document).each do |candidate|
296
+ nearest_surviving_ancestor_candidates_for(graph: graph, document: document).each do |candidate|
313
297
  graph.add_edge(
314
298
  parent_document: candidate.fetch(:document),
315
299
  child_document: document,
316
300
  tree_settings: candidate.fetch(:tree_settings),
317
301
  definition: candidate.fetch(:definition),
318
- source_description: 'pruning grandparents'
302
+ source_description: 'pruning ancestors'
319
303
  )
320
304
  end
321
305
 
322
306
  graph.parent_documents_for(document).any?
323
307
  end
324
308
 
325
- # Returns every surviving grandparent candidate for one orphan.
326
- def grandparent_candidates_for(document)
309
+ # Returns the first surviving ancestor on every original parent lineage.
310
+ def nearest_surviving_ancestor_candidates_for(graph:, document:)
327
311
  @provenance.original_parent_entries_for(document).each_with_object({}) do |parent_entry, candidates|
328
- @provenance.original_parent_entries_for(parent_entry.fetch(:document)).each do |grandparent_entry|
329
- grandparent_document = grandparent_entry.fetch(:document)
330
- next unless current_document?(grandparent_document)
331
-
332
- candidates[grandparent_document.object_id] ||= {
333
- document: grandparent_document,
312
+ nearest_surviving_ancestors_for(
313
+ graph: graph,
314
+ document: parent_entry.fetch(:document),
315
+ visited_document_ids: {}
316
+ ).each do |ancestor_document|
317
+ candidates[ancestor_document.object_id] ||= {
318
+ document: ancestor_document,
334
319
  definition: parent_entry.fetch(:definition),
335
320
  tree_settings: parent_entry.fetch(:tree_settings)
336
321
  }
@@ -338,9 +323,28 @@ class TreePhase
338
323
  end.values
339
324
  end
340
325
 
341
- # Returns true when one document is still active in the current build.
342
- def current_document?(document)
343
- @current_active_document_ids.key?(document.object_id)
326
+ # Walks removed provenance nodes until each lineage reaches an active document.
327
+ def nearest_surviving_ancestors_for(graph:, document:, visited_document_ids:)
328
+ return [] if visited_document_ids.key?(document.object_id)
329
+ return [document] if graph.active_document?(document)
330
+
331
+ lineage_visited_document_ids = visited_document_ids.merge(document.object_id => true)
332
+ @provenance.original_parent_entries_for(document).each_with_object({}) do |parent_entry, ancestors|
333
+ nearest_surviving_ancestors_for(
334
+ graph: graph,
335
+ document: parent_entry.fetch(:document),
336
+ visited_document_ids: lineage_visited_document_ids
337
+ ).each do |ancestor_document|
338
+ ancestors[ancestor_document.object_id] ||= ancestor_document
339
+ end
340
+ end.values
341
+ end
342
+
343
+ # Returns one document array with later duplicates removed by object id.
344
+ def unique_documents(documents)
345
+ Array(documents).each_with_object({}) do |document, unique_documents|
346
+ unique_documents[document.object_id] ||= document
347
+ end.values
344
348
  end
345
349
  end
346
350