Module: Ecoportal::API::GraphQL::Diff::Pairing::Signals

Defined in:
lib/ecoportal/api/graphql/diff/pairing/signals.rb

Overview

Weak pairing SIGNALS combined into a confidence score. Each signal scores a candidate (source-field-doc, target-field-doc) pair in 0.0..1.0. NONE is ground truth on its own — per the domain reference genomeSignature is strong-but-fallible (6 documented failure modes: manual/no-platform-support, new features never added, wrong for select options, wrongly syncs checklists, re-used/re-purposed fields, incomplete data-loss accounting). So genome is ONE signal among several, weighted highest but able to be outvoted / confirmed by type+label+options. The Engine aggregates these with WEIGHTS.

Constant Summary collapse

WEIGHTS =

Relative weights (need not sum to 1; the Engine normalises by the weights that applied).

{
  genome:  0.5,
  type:    0.2,
  label:   0.2,
  options: 0.1
}.freeze

Class Method Summary collapse

Class Method Details

.blank?(value) ⇒ Boolean

Returns:

  • (Boolean)


116
117
118
# File 'lib/ecoportal/api/graphql/diff/pairing/signals.rb', line 116

def blank?(value)
  value.nil? || value.to_s.strip.empty?
end

.genome(source, target) ⇒ Object

genomeSignature match. Strong but fallible, so a MATCH scores high (not 1.0) and a MISMATCH scores 0 rather than vetoing (the field may have been re-purposed keeping its genome, or genome may be absent on newer field types). Returns nil when neither side carries a genome — the signal simply does not apply and is excluded from the average.

LINEAGE (verified live 2026-08-05, same-org backup restore of a full template): a restore does NOT equal-copy the signature — it APPENDS one segment to it, for every membrane AND force (e.g. 2zwxkb6gns2zwxkb6gns53jp, ktnryaktnrya877l; 28/28 fields + 4/4 forces followed the rule). So an ancestor's signature is a strict PREFIX of its descendant's, and equality-only scoring would give the exact cross-object case this engine exists for (UAT backup → PROD restore) a 0.0 — worse than no signal. Prefix ancestry therefore scores just below an exact match.



36
37
38
39
40
41
42
43
44
# File 'lib/ecoportal/api/graphql/diff/pairing/signals.rb', line 36

def genome(source, target)
  a = source['genomeSignature']
  b = target['genomeSignature']
  return nil if blank?(a) && blank?(b)
  return 0.0 if blank?(a) || blank?(b)
  return 1.0 if a == b

  lineage?(a, b) ? 0.9 : 0.0
end

.jaccard(list_a, list_b) ⇒ Object

Jaccard similarity of two token collections (|∩| / |∪|). 0.0 when both empty.



106
107
108
109
110
111
112
113
114
# File 'lib/ecoportal/api/graphql/diff/pairing/signals.rb', line 106

def jaccard(list_a, list_b)
  set_a = list_a.to_a.uniq
  set_b = list_b.to_a.uniq
  return 0.0 if set_a.empty? && set_b.empty?

  inter = (set_a & set_b).size.to_f
  union = (set_a | set_b).size
  union.zero? ? 0.0 : inter / union
end

.label(source, target) ⇒ Object

Label similarity — exact (case/space-insensitive) = 1.0, else a token-overlap (Jaccard) score, so Date logged vs Date of sign-off still gets partial credit (a re-purpose candidate the human should adjudicate rather than an auto-accept).



70
71
72
73
74
75
76
77
# File 'lib/ecoportal/api/graphql/diff/pairing/signals.rb', line 70

def label(source, target)
  a = normalise(source['label'])
  b = normalise(target['label'])
  return nil if a.empty? && b.empty?
  return 1.0 if a == b && !a.empty?

  jaccard(a.split, b.split)
end

.lineage?(sig_a, sig_b) ⇒ Boolean

True when one signature is a strict prefix of the other (restore/copy appends a lineage segment — see #genome). Direction is unknowable at a pairing call (either side may be the older document), so both directions count. Very short signatures are rejected as ancestry evidence — a trivial prefix can collide by chance.

Returns:

  • (Boolean)


50
51
52
53
54
55
# File 'lib/ecoportal/api/graphql/diff/pairing/signals.rb', line 50

def lineage?(sig_a, sig_b)
  short, long = [sig_a.to_s, sig_b.to_s].minmax_by(&:length)
  return false if short.length < 4

  short != long && long.start_with?(short)
end

.normalise(value) ⇒ Object



93
94
95
# File 'lib/ecoportal/api/graphql/diff/pairing/signals.rb', line 93

def normalise(value)
  value.to_s.strip.downcase.gsub(/\s+/, ' ')
end

.option_keys(field) ⇒ Object



97
98
99
100
101
102
103
# File 'lib/ecoportal/api/graphql/diff/pairing/signals.rb', line 97

def option_keys(field)
  Array(field['options']).filter_map do |o|
    next unless o.is_a?(Hash)

    normalise(o['value'] || o['label'] || o['name'])
  end.reject(&:empty?)
end

.options(source, target) ⇒ Object

Select-option overlap by VALUE (primary) / label (auxiliary) — the CORRECT option identity, never genome. Returns nil for non-select fields (no options either side).



81
82
83
84
85
86
87
# File 'lib/ecoportal/api/graphql/diff/pairing/signals.rb', line 81

def options(source, target)
  a = option_keys(source)
  b = option_keys(target)
  return nil if a.empty? && b.empty?

  jaccard(a, b)
end

.type(source, target) ⇒ Object

Field type (__typename) equality. A type change is a strong DISQUALIFIER for a data pairing (you cannot faithfully migrate PlainText data into a Gauge), so a mismatch is 0.



59
60
61
62
63
64
65
# File 'lib/ecoportal/api/graphql/diff/pairing/signals.rb', line 59

def type(source, target)
  ta = type_of(source)
  tb = type_of(target)
  return nil if ta.nil? && tb.nil?

  ta == tb ? 1.0 : 0.0
end

.type_of(field) ⇒ Object



89
90
91
# File 'lib/ecoportal/api/graphql/diff/pairing/signals.rb', line 89

def type_of(field)
  field['__typename'] || field['type']
end