Class: FactDb::Resolution::FactResolver

Inherits:
Object
  • Object
show all
Defined in:
lib/fact_db/resolution/fact_resolver.rb

Overview

Handles fact lifecycle operations including supersession, synthesis, and conflict resolution

Provides methods for managing fact relationships: superseding outdated facts, synthesizing new facts from multiple sources, handling corroboration, and detecting/resolving conflicts.

Examples:

Supersede an outdated fact

resolver = FactResolver.new
new_fact = resolver.supersede(old_fact.id, "Updated information", valid_at: Date.today)

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config = FactDb.config) ⇒ FactResolver

Initializes a new FactResolver instance

Parameters:

  • config (FactDb::Config) (defaults to: FactDb.config)

    configuration object (defaults to FactDb.config)



22
23
24
# File 'lib/fact_db/resolution/fact_resolver.rb', line 22

def initialize(config = FactDb.config)
  @config = config
end

Instance Attribute Details

#configFactDb::Config (readonly)

Returns the configuration object.

Returns:



17
18
19
# File 'lib/fact_db/resolution/fact_resolver.rb', line 17

def config
  @config
end

Instance Method Details

#build_timeline_fact(entity_id:, topic: nil) ⇒ FactDb::Models::Fact?

Builds a timeline fact from point-in-time facts for an entity

Creates a synthesized fact summarizing the entity’s history on a topic.

Parameters:

  • entity_id (Integer)

    the entity ID

  • topic (String, nil) (defaults to: nil)

    optional topic filter

Returns:



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/fact_db/resolution/fact_resolver.rb', line 266

def build_timeline_fact(entity_id:, topic: nil)
  facts = Models::Fact.mentioning_entity(entity_id)
  facts = facts.search_text(topic) if topic
  facts = facts.order(valid_at: :asc).to_a

  return nil if facts.empty?

  # Find start and end dates
  start_date = facts.first.valid_at
  end_date = facts.select { |f| f.invalid_at }.map(&:invalid_at).max

  entity = Models::Entity.find(entity_id)
  synthesized_text = "#{entity.name}: #{topic || 'timeline'} from #{start_date.to_date}"
  synthesized_text += " to #{end_date.to_date}" if end_date

  synthesize(
    facts.map(&:id),
    synthesized_text,
    valid_at: start_date,
    invalid_at: end_date
  )
end

#corroborate(fact_id, corroborating_fact_id) ⇒ FactDb::Models::Fact

Marks a fact as corroborated by another fact

Adds the corroborating fact ID to the corroborated_by_ids array. If 2+ facts corroborate, status changes to “corroborated”.

Parameters:

  • fact_id (Integer)

    ID of the fact being corroborated

  • corroborating_fact_id (Integer)

    ID of the supporting fact

Returns:

Raises:



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/fact_db/resolution/fact_resolver.rb', line 172

def corroborate(fact_id, corroborating_fact_id)
  fact = Models::Fact.find(fact_id)
  _corroborating = Models::Fact.find(corroborating_fact_id)

  raise ResolutionError, "Cannot corroborate with same fact" if fact_id == corroborating_fact_id

  fact.update!(
    corroborated_by_ids: (fact.corroborated_by_ids + [corroborating_fact_id]).uniq
  )

  # Optionally update status to corroborated if it was just canonical
  fact.update!(status: "corroborated") if fact.status == "canonical" && fact.corroborated_by_ids.size >= 2

  fact
end

#find_conflicts(entity_id: nil, topic: nil) ⇒ Array<Hash>

Finds potentially conflicting facts

Identifies facts with similar text (50-95% similarity) that might be contradictory.

Parameters:

  • entity_id (Integer, nil) (defaults to: nil)

    entity ID to filter by

  • topic (String, nil) (defaults to: nil)

    topic to search for

Returns:

  • (Array<Hash>)

    array of hashes with :fact1, :fact2, :similarity keys



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/fact_db/resolution/fact_resolver.rb', line 206

def find_conflicts(entity_id: nil, topic: nil)
  scope = Models::Fact.canonical.currently_valid

  if entity_id
    scope = scope.mentioning_entity(entity_id)
  end

  if topic
    scope = scope.search_text(topic)
  end

  # Group facts that might be about the same thing
  facts = scope.to_a
  conflicts = []

  facts.each_with_index do |fact, i|
    facts[(i + 1)..].each do |other|
      similarity = text_similarity(fact.text, other.text)
      if similarity > 0.5 && similarity < 0.95
        conflicts << {
          fact1: fact,
          fact2: other,
          similarity: similarity
        }
      end
    end
  end

  conflicts.sort_by { |c| -c[:similarity] }
end

#invalidate(fact_id, at: Time.current) ⇒ FactDb::Models::Fact

Invalidates a fact without replacement

Parameters:

  • fact_id (Integer)

    ID of the fact to invalidate

  • at (Time) (defaults to: Time.current)

    when the fact became invalid (defaults to now)

Returns:



193
194
195
196
197
# File 'lib/fact_db/resolution/fact_resolver.rb', line 193

def invalidate(fact_id, at: Time.current)
  fact = Models::Fact.find(fact_id)
  fact.update!(invalid_at: at)
  fact
end

#resolve_conflict(keep_fact_id, supersede_fact_ids, reason: nil) ⇒ FactDb::Models::Fact

Resolves conflicts by keeping one fact and superseding others

Parameters:

  • keep_fact_id (Integer)

    ID of the fact to keep as canonical

  • supersede_fact_ids (Array<Integer>)

    IDs of facts to mark as superseded

  • reason (String, nil) (defaults to: nil)

    reason for the resolution (stored in metadata)

Returns:



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/fact_db/resolution/fact_resolver.rb', line 243

def resolve_conflict(keep_fact_id, supersede_fact_ids, reason: nil)
  Models::Fact.transaction do
    supersede_fact_ids.each do |fact_id|
      fact = Models::Fact.find(fact_id)
      fact.update!(
        status: "superseded",
        superseded_by_id: keep_fact_id,
        invalid_at: Time.current,
        metadata: fact..merge(supersede_reason: reason)
      )
    end
  end

  Models::Fact.find(keep_fact_id)
end

#supersede(old_fact_id, new_text, valid_at:, mentions: []) ⇒ FactDb::Models::Fact

Supersedes an existing fact with a new one

Creates a new canonical fact and marks the old one as superseded. Copies mentions and sources from the old fact unless new mentions are provided.

Examples:

Supersede with new mentions

resolver.supersede(fact.id, "John now works at NewCo",
  valid_at: Date.today,
  mentions: [{ entity_id: john.id, text: "John", role: :subject }])

Parameters:

  • old_fact_id (Integer)

    ID of the fact to supersede

  • new_text (String)

    the updated fact text

  • valid_at (Date, Time)

    when the new fact became valid

  • mentions (Array<Hash>) (defaults to: [])

    optional entity mentions for the new fact

Returns:

Raises:



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/fact_db/resolution/fact_resolver.rb', line 42

def supersede(old_fact_id, new_text, valid_at:, mentions: [])
  old_fact = Models::Fact.find(old_fact_id)

  raise ResolutionError, "Cannot supersede already superseded fact" if old_fact.superseded?

  Models::Fact.transaction do
    new_fact = Models::Fact.create!(
      text: new_text,
      valid_at: valid_at,
      status: "canonical",
      extraction_method: old_fact.extraction_method,
      confidence: old_fact.confidence
    )

    # Copy mentions from old fact if not provided
    if mentions.empty?
      old_fact.entity_mentions.each do |mention|
        new_fact.add_mention(
          entity: mention.entity,
          text: mention.mention_text,
          role: mention.mention_role,
          confidence: mention.confidence
        )
      end
    else
      mentions.each do |mention|
        entity = mention[:entity] || Models::Entity.find(mention[:entity_id])
        new_fact.add_mention(
          entity: entity,
          text: mention[:text],
          role: mention[:role],
          confidence: mention[:confidence] || 1.0
        )
      end
    end

    # Copy sources from old fact
    old_fact.fact_sources.each do |source|
      new_fact.add_source(
        source: source.source,
        kind: source.kind,
        excerpt: source.excerpt,
        confidence: source.confidence
      )
    end

    # Mark old fact as superseded
    old_fact.update!(
      status: "superseded",
      superseded_by_id: new_fact.id,
      invalid_at: valid_at
    )

    new_fact
  end
end

#synthesize(source_fact_ids, synthesized_text, valid_at:, invalid_at: nil, mentions: []) ⇒ FactDb::Models::Fact

Synthesizes a new fact from multiple source facts

Creates a single synthesized fact that aggregates information from multiple facts. Automatically aggregates entity mentions and links to all source content.

Examples:

Synthesize multiple facts

resolver.synthesize([fact1.id, fact2.id], "Summary of events", valid_at: Date.today)

Parameters:

  • source_fact_ids (Array<Integer>)

    IDs of the source facts

  • synthesized_text (String)

    the synthesized summary text

  • valid_at (Date, Time)

    when the synthesis is valid from

  • invalid_at (Date, Time, nil) (defaults to: nil)

    when the synthesis becomes invalid

  • mentions (Array<Hash>) (defaults to: [])

    optional entity mentions (aggregated from sources if empty)

Returns:

Raises:



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/fact_db/resolution/fact_resolver.rb', line 114

def synthesize(source_fact_ids, synthesized_text, valid_at:, invalid_at: nil, mentions: [])
  source_facts = Models::Fact.where(id: source_fact_ids)

  raise ResolutionError, "No source facts found" if source_facts.empty?

  Models::Fact.transaction do
    synthesized = Models::Fact.create!(
      text: synthesized_text,
      valid_at: valid_at,
      invalid_at: invalid_at,
      status: "synthesized",
      derived_from_ids: source_fact_ids,
      extraction_method: "synthesized",
      confidence: calculate_synthesized_confidence(source_facts)
    )

    # Aggregate entity mentions from source facts if not provided
    if mentions.empty?
      aggregate_mentions(source_facts).each do |mention|
        synthesized.add_mention(**mention)
      end
    else
      mentions.each do |mention|
        entity = mention[:entity] || Models::Entity.find(mention[:entity_id])
        synthesized.add_mention(
          entity: entity,
          text: mention[:text],
          role: mention[:role],
          confidence: mention[:confidence] || 1.0
        )
      end
    end

    # Link all source content
    source_facts.each do |source_fact|
      source_fact.fact_sources.each do |source|
        synthesized.add_source(
          content: source.content,
          type: "supporting",
          excerpt: source.excerpt,
          confidence: source.confidence
        )
      end
    end

    synthesized
  end
end