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.
- checksums.yaml +4 -4
- data/.ai-assistance/code/diff_pairing_engine.md +243 -0
- data/.ai-assistance/code/graphql_domain_knowledge.md +20 -10
- data/.ai-assistance/code/template_diff_pairing_domain.md +175 -0
- data/.ai-assistance/code/workflow-command-guide.md +28 -0
- data/.ai-assistance/projects/TODO.md +21 -0
- data/.ai-assistance/projects/ooze-graphql-native-migration/INVENTORY.md +136 -0
- data/.ai-assistance/projects/ooze-graphql-native-migration/TODO.md +6 -1
- data/.ai-assistance/projects/qa-services-delivery/DECISIONS.md +93 -0
- data/.ai-assistance/projects/qa-services-delivery/INTENT.md +76 -0
- data/.ai-assistance/projects/qa-services-delivery/PHASE3-SCOPE.md +115 -0
- data/.ai-assistance/projects/qa-services-delivery/ROADMAP.md +99 -0
- data/.ai-assistance/projects/qa-services-delivery/TODO.md +81 -0
- data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +77 -0
- data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +97 -0
- data/.ai-assistance/projects/template-diff-deploy/INTENT.md +12 -0
- data/.ai-assistance/projects/template-diff-deploy/TODO.md +9 -0
- data/.ai-assistance/projects/template-maintenance/PHASE0-FINDINGS.md +93 -0
- data/.ai-assistance/projects/template-maintenance/README.md +14 -0
- data/CHANGELOG.md +116 -0
- data/docs/worklog.md +408 -0
- data/ecoportal-api-graphql.gemspec +1 -1
- data/lib/ecoportal/api/graphql/base/page/data_field.rb +1 -1
- data/lib/ecoportal/api/graphql/builder/template_builder.rb +174 -0
- data/lib/ecoportal/api/graphql/builder.rb +17 -16
- data/lib/ecoportal/api/graphql/diff/change.rb +59 -0
- data/lib/ecoportal/api/graphql/diff/command_synthesizer.rb +329 -0
- data/lib/ecoportal/api/graphql/diff/cross_object_diff.rb +165 -0
- data/lib/ecoportal/api/graphql/diff/deploy.rb +121 -0
- data/lib/ecoportal/api/graphql/diff/id_resolver.rb +64 -0
- data/lib/ecoportal/api/graphql/diff/pairing/candidate.rb +32 -0
- data/lib/ecoportal/api/graphql/diff/pairing/engine.rb +173 -0
- data/lib/ecoportal/api/graphql/diff/pairing/ledger.rb +119 -0
- data/lib/ecoportal/api/graphql/diff/pairing/signals.rb +104 -0
- data/lib/ecoportal/api/graphql/diff/strategy.rb +113 -0
- data/lib/ecoportal/api/graphql/diff/version_diff.rb +332 -0
- data/lib/ecoportal/api/graphql/diff.rb +34 -0
- data/lib/ecoportal/api/graphql/fragment/location_draft.rb +53 -53
- data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +1 -0
- data/lib/ecoportal/api/graphql/input/workflow_command/add_field.rb +27 -18
- data/lib/ecoportal/api/graphql/mutation/action/archive.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/action/create.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/action/update.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/contractor_entity/create.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/contractor_entity/destroy.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/contractor_entity/update.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/kickstand/fail_workflow.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/kickstand/start_workflow.rb +1 -1
- data/lib/ecoportal/api/graphql/mutation/kickstand/stop_workflow.rb +1 -1
- data/lib/ecoportal/api/graphql/query/location_structure/draft.rb +62 -62
- data/lib/ecoportal/api/graphql/query/location_structure.rb +61 -61
- data/lib/ecoportal/api/graphql.rb +139 -138
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- data/tests/dump_template_model.rb +90 -0
- data/tests/validate_queries.rb +127 -0
- 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
|