ecoportal-api-graphql 1.3.9 → 1.3.11

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.ai-assistance/code/diff_pairing_engine.md +243 -0
  3. data/.ai-assistance/code/graphql_domain_knowledge.md +20 -10
  4. data/.ai-assistance/code/template_diff_pairing_domain.md +175 -0
  5. data/.ai-assistance/code/workflow-command-guide.md +28 -0
  6. data/.ai-assistance/projects/TODO.md +21 -0
  7. data/.ai-assistance/projects/ooze-graphql-native-migration/INVENTORY.md +136 -0
  8. data/.ai-assistance/projects/ooze-graphql-native-migration/TODO.md +6 -1
  9. data/.ai-assistance/projects/qa-services-delivery/DECISIONS.md +93 -0
  10. data/.ai-assistance/projects/qa-services-delivery/INTENT.md +76 -0
  11. data/.ai-assistance/projects/qa-services-delivery/PHASE3-SCOPE.md +115 -0
  12. data/.ai-assistance/projects/qa-services-delivery/ROADMAP.md +99 -0
  13. data/.ai-assistance/projects/qa-services-delivery/TODO.md +81 -0
  14. data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +77 -0
  15. data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +97 -0
  16. data/.ai-assistance/projects/template-diff-deploy/INTENT.md +12 -0
  17. data/.ai-assistance/projects/template-diff-deploy/TODO.md +9 -0
  18. data/.ai-assistance/projects/template-maintenance/PHASE0-FINDINGS.md +93 -0
  19. data/.ai-assistance/projects/template-maintenance/README.md +14 -0
  20. data/CHANGELOG.md +116 -0
  21. data/docs/worklog.md +408 -0
  22. data/ecoportal-api-graphql.gemspec +1 -1
  23. data/lib/ecoportal/api/graphql/base/page/data_field.rb +1 -1
  24. data/lib/ecoportal/api/graphql/builder/template_builder.rb +174 -0
  25. data/lib/ecoportal/api/graphql/builder.rb +17 -16
  26. data/lib/ecoportal/api/graphql/diff/change.rb +59 -0
  27. data/lib/ecoportal/api/graphql/diff/command_synthesizer.rb +329 -0
  28. data/lib/ecoportal/api/graphql/diff/cross_object_diff.rb +165 -0
  29. data/lib/ecoportal/api/graphql/diff/deploy.rb +121 -0
  30. data/lib/ecoportal/api/graphql/diff/id_resolver.rb +64 -0
  31. data/lib/ecoportal/api/graphql/diff/pairing/candidate.rb +32 -0
  32. data/lib/ecoportal/api/graphql/diff/pairing/engine.rb +173 -0
  33. data/lib/ecoportal/api/graphql/diff/pairing/ledger.rb +119 -0
  34. data/lib/ecoportal/api/graphql/diff/pairing/signals.rb +104 -0
  35. data/lib/ecoportal/api/graphql/diff/strategy.rb +113 -0
  36. data/lib/ecoportal/api/graphql/diff/version_diff.rb +332 -0
  37. data/lib/ecoportal/api/graphql/diff.rb +34 -0
  38. data/lib/ecoportal/api/graphql/fragment/location_draft.rb +53 -53
  39. data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +1 -0
  40. data/lib/ecoportal/api/graphql/input/workflow_command/add_field.rb +27 -18
  41. data/lib/ecoportal/api/graphql/mutation/action/archive.rb +1 -1
  42. data/lib/ecoportal/api/graphql/mutation/action/create.rb +1 -1
  43. data/lib/ecoportal/api/graphql/mutation/action/update.rb +1 -1
  44. data/lib/ecoportal/api/graphql/mutation/contractor_entity/create.rb +1 -1
  45. data/lib/ecoportal/api/graphql/mutation/contractor_entity/destroy.rb +1 -1
  46. data/lib/ecoportal/api/graphql/mutation/contractor_entity/update.rb +1 -1
  47. data/lib/ecoportal/api/graphql/mutation/kickstand/fail_workflow.rb +1 -1
  48. data/lib/ecoportal/api/graphql/mutation/kickstand/start_workflow.rb +1 -1
  49. data/lib/ecoportal/api/graphql/mutation/kickstand/stop_workflow.rb +1 -1
  50. data/lib/ecoportal/api/graphql/query/location_structure/draft.rb +62 -62
  51. data/lib/ecoportal/api/graphql/query/location_structure.rb +61 -61
  52. data/lib/ecoportal/api/graphql.rb +139 -138
  53. data/lib/ecoportal/api/graphql_version.rb +1 -1
  54. data/tests/dump_template_model.rb +90 -0
  55. data/tests/validate_queries.rb +127 -0
  56. metadata +32 -3
@@ -0,0 +1,64 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Diff
5
+ # Resolves the human keys a `VersionDiff` records (stage NAME, section HEADING) into the
6
+ # target-doc ids that move commands need (moveField -> section id, add/removeStageSection ->
7
+ # stage id). A structural diff only knows those human keys; this fills the gap WITHOUT
8
+ # guessing — it looks them up in a real target doc (the deploy destination), so
9
+ # `CommandSynthesizer` can emit faithful move commands.
10
+ #
11
+ # Answers `resolve(kind, key) => id | nil`. A nil (ambiguous / not found) keeps the move
12
+ # UNSUPPORTED rather than picking a wrong id.
13
+ #
14
+ # resolver = IdResolver.from_doc(prod_template_doc)
15
+ # CommandSynthesizer.new(changes, resolver: resolver)
16
+ #
17
+ # AMBIGUITY — if two stages share a name (or two sections a heading), the key is ambiguous
18
+ # and resolves to nil (the caller must disambiguate). Uniqueness is the safe default.
19
+ class IdResolver
20
+ # Build from an explicit map: { stage: { 'Report' => 'stg1' }, section: { 'Location' => 'sec1' } }
21
+ def initialize(index = {})
22
+ @index = index
23
+ end
24
+
25
+ # Build a resolver by indexing a page/template doc's stages (by name) and sections
26
+ # (by heading). Duplicate keys are dropped (resolve -> nil) so we never pick arbitrarily.
27
+ def self.from_doc(doc)
28
+ doc ||= {}
29
+ stages = {}
30
+ sections = {}
31
+ Array(doc['stages']).each do |st|
32
+ mark(stages, st['name'], st['id'])
33
+ Array(st['sections']).each { |sec| mark(sections, sec['heading'], sec['id']) }
34
+ end
35
+ new(stage: strip_ambiguous(stages), section: strip_ambiguous(sections))
36
+ end
37
+
38
+ # @return [String,nil] the target id for (kind, key), or nil if unknown/ambiguous.
39
+ def resolve(kind, key)
40
+ @index.dig(kind.to_sym, key)
41
+ end
42
+
43
+ class << self
44
+ private
45
+
46
+ def mark(bucket, key, id)
47
+ return if key.nil? || id.nil?
48
+
49
+ (bucket[key] ||= []) << id
50
+ end
51
+
52
+ # Keep only keys that map to exactly one id; ambiguous keys are omitted (resolve -> nil).
53
+ def strip_ambiguous(bucket)
54
+ bucket.each_with_object({}) do |(key, ids), out|
55
+ uniq = ids.uniq
56
+ out[key] = uniq.first if uniq.size == 1
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,32 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Diff
5
+ module Pairing
6
+ # A scored pairing proposal: source object (id-space A) <-> target object (id-space B),
7
+ # with the aggregate confidence and the per-signal breakdown that produced it.
8
+ #
9
+ # `source`/`target` are the raw field docs (Hashes) being paired. `score` is 0.0..1.0.
10
+ # `signals` maps signal-name => contribution (for transparency + the ledger + Product's
11
+ # Field-ID data). `matched_by` names the dominant signal (e.g. :genome, :label, :ledger).
12
+ Candidate = Struct.new(:source, :target, :score, :signals, :matched_by, keyword_init: true) do
13
+ def source_id
14
+ source && source['id']
15
+ end
16
+
17
+ def target_id
18
+ target && target['id']
19
+ end
20
+
21
+ def to_h
22
+ {
23
+ source_id: source_id, target_id: target_id, score: score,
24
+ matched_by: matched_by, signals: signals
25
+ }.compact
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,173 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Diff
5
+ module Pairing
6
+ # The equivalence-matching engine for CROSS-OBJECT pairing (UAT<->PROD, page<->template).
7
+ #
8
+ # Given two lists of field docs living in different id-spaces, it proposes pairings by:
9
+ # 1. consulting the LEDGER first — a previously-confirmed pair auto-resolves (method
10
+ # :ledger, confidence 1.0), so pairing improves over time and only novelty is scored;
11
+ # 2. otherwise scoring every remaining source×target candidate with multi-signal
12
+ # confidence (genome + type + label + options — see Signals), picking the best target
13
+ # per source in a stable, greedy, one-to-one assignment (highest scores first).
14
+ #
15
+ # It then CLASSIFIES each best candidate:
16
+ # - `accepted` — score >= accept_threshold: high confidence, auto-paired;
17
+ # - `ambiguous` — accept > score >= review_threshold, OR the top two candidates are within
18
+ # `tie_margin` (genuinely close): route to a HUMAN to adjudicate;
19
+ # - `unmatched` — no target scored >= review_threshold: escalate as a novelty.
20
+ #
21
+ # NEVER guesses: only `accepted` pairs are safe to auto-apply / auto-record; ambiguous and
22
+ # unmatched are surfaced for human resolution. Confirmed decisions are written back to the
23
+ # ledger by the caller (or via #confirm!) so the next run needs no human on them.
24
+ class Engine
25
+ DEFAULTS = { accept_threshold: 0.85, review_threshold: 0.5, tie_margin: 0.1 }.freeze
26
+
27
+ Result = Struct.new(:accepted, :ambiguous, :unmatched, keyword_init: true) do
28
+ # source field docs with no acceptable target — the pure novelty set.
29
+ def resolved?
30
+ ambiguous.empty? && unmatched.empty?
31
+ end
32
+
33
+ def to_h
34
+ {
35
+ accepted: accepted.map(&:to_h),
36
+ ambiguous: ambiguous.map(&:to_h),
37
+ unmatched: unmatched.map { |f| { source_id: f['id'], label: f['label'] } }
38
+ }
39
+ end
40
+ end
41
+
42
+ # @param ledger [Ledger,nil] consulted first + written to on #confirm!.
43
+ # @param kind [Symbol] entity kind recorded in the ledger (default :field).
44
+ def initialize(ledger: nil, kind: :field, **thresholds)
45
+ @ledger = ledger
46
+ @kind = kind
47
+ @cfg = DEFAULTS.merge(thresholds)
48
+ end
49
+
50
+ # Pair `sources` (id-space A) to `targets` (id-space B). Returns a Result.
51
+ def pair(sources, targets)
52
+ sources = Array(sources)
53
+ targets = Array(targets)
54
+
55
+ accepted, remaining_sources, remaining_targets = apply_ledger(sources, targets)
56
+ scored = score_all(remaining_sources, remaining_targets)
57
+ assign(scored, remaining_sources, accepted)
58
+ end
59
+
60
+ # Persist a confirmed pairing to the ledger (auto-accept or human decision). No-op
61
+ # without a ledger. `matched_by` overrides the candidate's dominant signal (e.g. :human
62
+ # when a person adjudicated). Returns the recorded Entry (or nil).
63
+ def confirm!(candidate, matched_by: nil)
64
+ return nil unless @ledger
65
+
66
+ @ledger.record(
67
+ kind: @kind, source_id: candidate.source_id, target_id: candidate.target_id,
68
+ matched_by: matched_by || candidate.matched_by, confidence: candidate.score,
69
+ signals: candidate.signals
70
+ )
71
+ end
72
+
73
+ private
74
+
75
+ # Pull out sources already confirmed in the ledger (auto-resolved), consuming the matched
76
+ # targets so they cannot be re-assigned. Returns [accepted, sources_left, targets_left].
77
+ def apply_ledger(sources, targets)
78
+ return [[], sources, targets] unless @ledger
79
+
80
+ by_id = targets.each_with_object({}) { |t, h| h[t['id']] = t if t['id'] }
81
+ accepted = []
82
+ used = []
83
+
84
+ left = sources.reject do |src|
85
+ tid = @ledger.target_for(@kind, src['id'])
86
+ tgt = tid && by_id[tid]
87
+ next false unless tgt
88
+
89
+ accepted << ledger_candidate(src, tgt)
90
+ used << tid
91
+ true
92
+ end
93
+
94
+ [accepted, left, targets.reject { |t| used.include?(t['id']) }]
95
+ end
96
+
97
+ def ledger_candidate(source, target)
98
+ Candidate.new(source: source, target: target, score: 1.0, matched_by: :ledger,
99
+ signals: { ledger: 1.0 })
100
+ end
101
+
102
+ # Score every source×target pair; keep only those at/above the review threshold, best
103
+ # first, so the greedy assignment takes the strongest links first.
104
+ def score_all(sources, targets)
105
+ sources.flat_map do |src|
106
+ targets.map { |tgt| score_pair(src, tgt) }
107
+ end.select { |c| c.score >= @cfg[:review_threshold] }.sort_by { |c| -c.score }
108
+ end
109
+
110
+ def score_pair(source, target)
111
+ parts = {
112
+ genome: Signals.genome(source, target),
113
+ type: Signals.type(source, target),
114
+ label: Signals.label(source, target),
115
+ options: Signals.options(source, target)
116
+ }
117
+ applied = parts.compact
118
+ score = weighted_average(applied)
119
+ Candidate.new(source: source, target: target, score: score,
120
+ signals: applied, matched_by: dominant(applied))
121
+ end
122
+
123
+ # Weighted mean over the signals that APPLIED (nil signals are excluded and their weight
124
+ # dropped), so a select field is not penalised for genome being absent, etc.
125
+ def weighted_average(applied)
126
+ return 0.0 if applied.empty?
127
+
128
+ num = applied.sum { |name, val| Signals::WEIGHTS.fetch(name, 0) * val }
129
+ den = applied.sum { |name, _| Signals::WEIGHTS.fetch(name, 0) }
130
+ den.zero? ? 0.0 : (num / den).round(4)
131
+ end
132
+
133
+ def dominant(applied)
134
+ return nil if applied.empty?
135
+
136
+ applied.max_by { |name, val| [val, Signals::WEIGHTS.fetch(name, 0)] }.first
137
+ end
138
+
139
+ # Greedy one-to-one assignment: walk best-scored candidates first, take a pair when both
140
+ # ends are still free. Then classify the taken pairs and collect unmatched sources.
141
+ def assign(scored, sources, accepted)
142
+ ambiguous = []
143
+ taken_src = accepted.map(&:source_id)
144
+ taken_tgt = accepted.map(&:target_id)
145
+ best_by_src = scored.group_by(&:source_id)
146
+
147
+ scored.each do |cand|
148
+ next if taken_src.include?(cand.source_id) || taken_tgt.include?(cand.target_id)
149
+
150
+ bucket = accept?(cand, best_by_src[cand.source_id]) ? accepted : ambiguous
151
+ bucket << cand
152
+ taken_src << cand.source_id
153
+ taken_tgt << cand.target_id
154
+ end
155
+
156
+ unmatched = sources.reject { |s| taken_src.include?(s['id']) }
157
+ Result.new(accepted: accepted, ambiguous: ambiguous, unmatched: unmatched)
158
+ end
159
+
160
+ # Accept only when confidently above threshold AND not in a near-tie with the runner-up
161
+ # for the same source (a close second means the human should decide).
162
+ def accept?(cand, siblings)
163
+ return false if cand.score < @cfg[:accept_threshold]
164
+
165
+ runner_up = Array(siblings).reject { |c| c.target_id == cand.target_id }.map(&:score).max
166
+ runner_up.nil? || (cand.score - runner_up) >= @cfg[:tie_margin]
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,119 @@
1
+ require 'json'
2
+ require 'time'
3
+
4
+ module Ecoportal
5
+ module API
6
+ class GraphQL
7
+ module Diff
8
+ module Pairing
9
+ # The LEARNING LEDGER — a first-class artifact that records CONFIRMED equivalences between
10
+ # objects that live in different id-spaces (UAT<->PROD, page<->template). Because MongoDB
11
+ # gives every object a distinct id and nothing is shared across counterparts, pairing is an
12
+ # equivalence / entity-resolution problem, not a lookup. Every decision the engine (or a
13
+ # human) makes is persisted here so pairing IMPROVES over time: on the next run the ledger
14
+ # is consulted FIRST and previously-resolved pairs auto-resolve, leaving only genuine
15
+ # novelty for the human to adjudicate.
16
+ #
17
+ # An entry is keyed by (kind, source_id) and records the paired target_id plus HOW it was
18
+ # resolved (the method, the confidence, the signals, a timestamp). This log of *how* is the
19
+ # bridge data Product's Field-ID / template-entity-id effort needs.
20
+ #
21
+ # ledger = Ledger.load('pairings.json') # or Ledger.new (in-memory)
22
+ # ledger.record(kind: :field, source_id: 'a', target_id: 'b',
23
+ # method: :genome, confidence: 0.98, signals: {...})
24
+ # ledger.lookup(:field, 'a') # => Entry or nil
25
+ # ledger.save # persist back to the same path
26
+ #
27
+ # SAFETY — the ledger stores only decisions that were CONFIRMED (auto-accepted at high
28
+ # confidence, or human-adjudicated). Ambiguous/low-confidence candidates are never written;
29
+ # they are escalated. The ledger is thus a growing store of ground truth, never guesses.
30
+ class Ledger
31
+ Entry = Struct.new(:kind, :source_id, :target_id, :matched_by, :confidence, :signals,
32
+ :recorded_at, keyword_init: true) do
33
+ def to_h
34
+ {
35
+ kind: kind, source_id: source_id, target_id: target_id, matched_by: matched_by,
36
+ confidence: confidence, signals: signals, recorded_at: recorded_at
37
+ }.compact
38
+ end
39
+ end
40
+
41
+ # @param entries [Array<Entry,Hash>] existing entries (e.g. from a loaded file).
42
+ # @param path [String,nil] where #save writes; defaults to the load path.
43
+ def initialize(entries: [], path: nil)
44
+ @path = path
45
+ @entries = {}
46
+ Array(entries).each { |e| add_entry(coerce(e)) }
47
+ end
48
+
49
+ # Load a ledger from a JSON file. A missing file yields an empty (still writable) ledger.
50
+ def self.load(path)
51
+ data = File.exist?(path) ? JSON.parse(File.read(path)) : {}
52
+ new(entries: data['entries'] || [], path: path)
53
+ end
54
+
55
+ # Record a CONFIRMED equivalence. `matched_by` names how it was resolved (:genome,
56
+ # :label, :ledger, :human, ...). Later records for the same (kind, source_id) supersede
57
+ # earlier ones (a human can correct an auto-accept). Returns the stored Entry.
58
+ def record(kind:, source_id:, target_id:, matched_by:, confidence: nil, signals: nil)
59
+ entry = Entry.new(
60
+ kind: kind.to_sym, source_id: source_id, target_id: target_id,
61
+ matched_by: matched_by&.to_sym, confidence: confidence, signals: signals,
62
+ recorded_at: Time.now.utc.iso8601
63
+ )
64
+ add_entry(entry)
65
+ entry
66
+ end
67
+
68
+ # The confirmed Entry for (kind, source_id), or nil if never resolved.
69
+ def lookup(kind, source_id)
70
+ @entries[[kind.to_sym, source_id]]
71
+ end
72
+
73
+ # The confirmed target id for (kind, source_id), or nil.
74
+ def target_for(kind, source_id)
75
+ lookup(kind, source_id)&.target_id
76
+ end
77
+
78
+ def entries
79
+ @entries.values
80
+ end
81
+
82
+ def size
83
+ @entries.size
84
+ end
85
+
86
+ def to_h
87
+ { entries: entries.map(&:to_h) }
88
+ end
89
+
90
+ # Persist to `path` (or the load path). Returns the path written.
91
+ def save(path = @path)
92
+ raise ArgumentError, 'no path to save the ledger to' if path.nil?
93
+
94
+ File.write(path, JSON.pretty_generate(to_h))
95
+ path
96
+ end
97
+
98
+ private
99
+
100
+ def add_entry(entry)
101
+ @entries[[entry.kind, entry.source_id]] = entry
102
+ end
103
+
104
+ def coerce(entry)
105
+ return entry if entry.is_a?(Entry)
106
+
107
+ h = entry.transform_keys(&:to_sym)
108
+ Entry.new(
109
+ kind: h[:kind]&.to_sym, source_id: h[:source_id], target_id: h[:target_id],
110
+ matched_by: h[:matched_by]&.to_sym, confidence: h[:confidence], signals: h[:signals],
111
+ recorded_at: h[:recorded_at]
112
+ )
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,104 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Diff
5
+ module Pairing
6
+ # Weak pairing SIGNALS combined into a confidence score. Each signal scores a candidate
7
+ # (source-field-doc, target-field-doc) pair in 0.0..1.0. NONE is ground truth on its own —
8
+ # per the domain reference genomeSignature is strong-but-fallible (6 documented failure
9
+ # modes: manual/no-platform-support, new features never added, wrong for select options,
10
+ # wrongly syncs checklists, re-used/re-purposed fields, incomplete data-loss accounting).
11
+ # So genome is ONE signal among several, weighted highest but able to be outvoted /
12
+ # confirmed by type+label+options. The Engine aggregates these with `WEIGHTS`.
13
+ module Signals
14
+ module_function
15
+
16
+ # Relative weights (need not sum to 1; the Engine normalises by the weights that applied).
17
+ WEIGHTS = {
18
+ genome: 0.5,
19
+ type: 0.2,
20
+ label: 0.2,
21
+ options: 0.1
22
+ }.freeze
23
+
24
+ # genomeSignature match. Strong but fallible, so a MATCH scores high (not 1.0) and a
25
+ # MISMATCH scores 0 rather than vetoing (the field may have been re-purposed keeping its
26
+ # genome, or genome may be absent on newer field types). Returns nil when neither side
27
+ # carries a genome — the signal simply does not apply and is excluded from the average.
28
+ def genome(source, target)
29
+ a = source['genomeSignature']
30
+ b = target['genomeSignature']
31
+ return nil if blank?(a) && blank?(b)
32
+ return 0.0 if blank?(a) || blank?(b)
33
+
34
+ a == b ? 1.0 : 0.0
35
+ end
36
+
37
+ # Field type (__typename) equality. A type change is a strong DISQUALIFIER for a data
38
+ # pairing (you cannot faithfully migrate PlainText data into a Gauge), so a mismatch is 0.
39
+ def type(source, target)
40
+ ta = type_of(source)
41
+ tb = type_of(target)
42
+ return nil if ta.nil? && tb.nil?
43
+
44
+ ta == tb ? 1.0 : 0.0
45
+ end
46
+
47
+ # Label similarity — exact (case/space-insensitive) = 1.0, else a token-overlap
48
+ # (Jaccard) score, so `Date logged` vs `Date of sign-off` still gets partial credit
49
+ # (a re-purpose candidate the human should adjudicate rather than an auto-accept).
50
+ def label(source, target)
51
+ a = normalise(source['label'])
52
+ b = normalise(target['label'])
53
+ return nil if a.empty? && b.empty?
54
+ return 1.0 if a == b && !a.empty?
55
+
56
+ jaccard(a.split, b.split)
57
+ end
58
+
59
+ # Select-option overlap by VALUE (primary) / label (auxiliary) — the CORRECT option
60
+ # identity, never genome. Returns nil for non-select fields (no options either side).
61
+ def options(source, target)
62
+ a = option_keys(source)
63
+ b = option_keys(target)
64
+ return nil if a.empty? && b.empty?
65
+
66
+ jaccard(a, b)
67
+ end
68
+
69
+ def type_of(field)
70
+ field['__typename'] || field['type']
71
+ end
72
+
73
+ def normalise(value)
74
+ value.to_s.strip.downcase.gsub(/\s+/, ' ')
75
+ end
76
+
77
+ def option_keys(field)
78
+ Array(field['options']).filter_map do |o|
79
+ next unless o.is_a?(Hash)
80
+
81
+ normalise(o['value'] || o['label'] || o['name'])
82
+ end.reject(&:empty?)
83
+ end
84
+
85
+ # Jaccard similarity of two token collections (|∩| / |∪|). 0.0 when both empty.
86
+ def jaccard(list_a, list_b)
87
+ set_a = list_a.to_a.uniq
88
+ set_b = list_b.to_a.uniq
89
+ return 0.0 if set_a.empty? && set_b.empty?
90
+
91
+ inter = (set_a & set_b).size.to_f
92
+ union = (set_a | set_b).size
93
+ union.zero? ? 0.0 : inter / union
94
+ end
95
+
96
+ def blank?(value)
97
+ value.nil? || value.to_s.strip.empty?
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,113 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Diff
5
+ # Composable configuration for a diff. There is NO single diff — different processes need
6
+ # different modes (see `template_diff_pairing_domain.md` §8). A `Strategy` is the value object
7
+ # that names the axes, consumed by the diff front-end (`VersionDiff` / `CrossObjectDiff`) and
8
+ # by `Deploy`.
9
+ #
10
+ # Axes:
11
+ #
12
+ # * **pairing** — how counterpart objects are identified across the two snapshots:
13
+ # :id same object, retained Mongo ids (self-version) — NO equivalence needed;
14
+ # :genome pair by genomeSignature (cross-object; a strong-but-fallible signal);
15
+ # :type_label pair by __typename + label (cross-object; the TypedFieldsPairing precedent);
16
+ # :assisted the full multi-signal `Pairing::Engine` (+ `Ledger`), human-in-the-loop
17
+ # for ambiguous/unmatched — the general cross-object case.
18
+ # `:id` is the default and keeps the existing self-version behaviour untouched.
19
+ #
20
+ # * **scope** — which change kinds are emitted:
21
+ # :structural stages / sections / fields (add/remove/move/relabel/retype) + options + gauge;
22
+ # :config_only field configuration (byType), select options, gauge stops — NOT structure;
23
+ # :data_migration field<->field data pairing only (structure-agnostic): pairings + type/label
24
+ # changes on paired fields, no add/remove of scaffolding.
25
+ #
26
+ # * **move_sensitive** — when false, section/stage MOVES are suppressed (a section-agnostic
27
+ # diff that only cares about a field's data pairing, not where it now lives).
28
+ #
29
+ # * **intent** — the consuming process (documentation only; does not alter emission, but lets a
30
+ # caller/`Deploy` branch on it):
31
+ # :changelog what changed, for a ticket / review checklist;
32
+ # :deploy UAT->PROD replay delta (drives placeholderId threading ON in Deploy);
33
+ # :sync_readiness can this register/subset be synced to the active template.
34
+ #
35
+ # Cross-object pairings (:genome/:type_label/:assisted) require a pairing map to translate the
36
+ # two id-spaces before change emission — see `CrossObjectDiff`. `:id` needs no map.
37
+ class Strategy
38
+ PAIRINGS = %i[id genome type_label assisted].freeze
39
+ SCOPES = %i[structural config_only data_migration].freeze
40
+ INTENTS = %i[changelog deploy sync_readiness].freeze
41
+
42
+ # Change KINDS considered structure vs configuration — used to filter by `scope`.
43
+ STRUCTURAL_KINDS = %i[stage section field].freeze
44
+ CONFIG_KINDS = %i[field_config option gauge_stop].freeze
45
+
46
+ attr_reader :pairing, :scope, :intent
47
+
48
+ def initialize(pairing: :id, scope: :structural, move_sensitive: true, intent: :changelog)
49
+ @pairing = validate!(:pairing, pairing, PAIRINGS)
50
+ @scope = validate!(:scope, scope, SCOPES)
51
+ @move_sensitive = move_sensitive ? true : false
52
+ @intent = validate!(:intent, intent, INTENTS)
53
+ end
54
+
55
+ # The default self-version strategy (id-paired, structural, move-aware, changelog). This is
56
+ # exactly the behaviour `VersionDiff` had before Phase 4 — used when no strategy is passed.
57
+ def self.default
58
+ new
59
+ end
60
+
61
+ def move_sensitive?
62
+ @move_sensitive
63
+ end
64
+
65
+ # True when pairing does NOT rely on retained ids — the two snapshots live in different
66
+ # id-spaces and need a pairing map (`CrossObjectDiff`) before change emission.
67
+ def cross_object?
68
+ @pairing != :id
69
+ end
70
+
71
+ # Should a change of this KIND be emitted under this scope?
72
+ def emit_kind?(kind)
73
+ case @scope
74
+ when :structural then true
75
+ when :config_only then CONFIG_KINDS.include?(kind)
76
+ when :data_migration then kind == :field # field-level data pairing only
77
+ end
78
+ end
79
+
80
+ # Should a :moved change be emitted? Suppressed when move-insensitive, or under a scope that
81
+ # ignores scaffolding placement (config_only / data_migration never care about moves).
82
+ def emit_move?
83
+ move_sensitive? && @scope == :structural
84
+ end
85
+
86
+ # Filter a raw change-set to what this strategy emits (scope + move-sensitivity). The diff
87
+ # front-ends compute the full change-set once and let the strategy decide what survives.
88
+ def filter(changes)
89
+ Array(changes).select do |change|
90
+ next false unless emit_kind?(change.kind)
91
+ next false if change.op == :moved && !emit_move?
92
+
93
+ true
94
+ end
95
+ end
96
+
97
+ def to_h
98
+ { pairing: @pairing, scope: @scope, move_sensitive: move_sensitive?, intent: @intent }
99
+ end
100
+
101
+ private
102
+
103
+ def validate!(axis, value, allowed)
104
+ sym = value&.to_sym
105
+ return sym if allowed.include?(sym)
106
+
107
+ raise ArgumentError, "invalid #{axis}: #{value.inspect} (expected one of #{allowed.inspect})"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end