Class: FactDb::Models::Fact

Inherits:
ActiveRecord::Base
  • Object
show all
Defined in:
lib/fact_db/models/fact.rb

Overview

Represents a temporal fact in the database

Facts are the core data structure in FactDb, representing statements with temporal validity (valid_at/invalid_at), entity mentions, and source provenance. Facts can be canonical, superseded, or synthesized from other facts.

Examples:

Create a fact

fact = Fact.create!(
  text: "John works at Acme Corp",
  valid_at: Date.parse("2024-01-15"),
  status: "canonical"
)

Query currently valid facts

Fact.canonical.currently_valid

Constant Summary collapse

STATUSES =

Returns valid fact statuses.

Returns:

  • (Array<String>)

    valid fact statuses

%w[canonical superseded corroborated synthesized].freeze
EXTRACTION_METHODS =

Returns valid extraction methods.

Returns:

  • (Array<String>)

    valid extraction methods

%w[manual llm rule_based].freeze

Scopes collapse

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.nearest_neighbors(embedding, limit: 10) ⇒ ActiveRecord::Relation

Finds facts by vector similarity using pgvector

Parameters:

  • embedding (Array<Float>)

    the embedding vector to search with

  • limit (Integer) (defaults to: 10)

    maximum number of results

Returns:

  • (ActiveRecord::Relation)

    facts ordered by similarity



410
411
412
413
414
# File 'lib/fact_db/models/fact.rb', line 410

def self.nearest_neighbors(embedding, limit: 10)
  return none unless embedding

  order(Arel.sql("embedding <=> '#{embedding}'")).limit(limit)
end

Instance Method Details

#add_mention(entity:, text:, role: nil, confidence: 1.0) ⇒ FactDb::Models::EntityMention

Adds an entity mention to this fact

Parameters:

  • entity (FactDb::Models::Entity)

    the entity being mentioned

  • text (String)

    the mention text as it appears in the fact

  • role (String, Symbol, nil) (defaults to: nil)

    the role (subject, object, etc.)

  • confidence (Float) (defaults to: 1.0)

    confidence score (0.0 to 1.0)

Returns:



255
256
257
258
259
260
# File 'lib/fact_db/models/fact.rb', line 255

def add_mention(entity:, text:, role: nil, confidence: 1.0)
  entity_mentions.find_or_create_by!(entity: entity, mention_text: text) do |m|
    m.mention_role = role
    m.confidence = confidence
  end
end

#add_source(source:, kind: "primary", excerpt: nil, confidence: 1.0) ⇒ FactDb::Models::FactSource

Adds a source document to this fact

Parameters:

  • source (FactDb::Models::Source)

    the source document

  • kind (String) (defaults to: "primary")

    source kind (primary, corroborating, etc.)

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

    relevant excerpt from the source

  • confidence (Float) (defaults to: 1.0)

    confidence score (0.0 to 1.0)

Returns:



269
270
271
272
273
274
275
# File 'lib/fact_db/models/fact.rb', line 269

def add_source(source:, kind: "primary", excerpt: nil, confidence: 1.0)
  fact_sources.find_or_create_by!(source: source) do |s|
    s.kind = kind
    s.excerpt = excerpt
    s.confidence = confidence
  end
end

#became_invalid_between(from, to) ⇒ ActiveRecord::Relation

Returns facts that became invalid within a date range

Parameters:

  • from (Date, Time)

    start of range

  • to (Date, Time)

    end of range

Returns:

  • (ActiveRecord::Relation)


112
113
114
# File 'lib/fact_db/models/fact.rb', line 112

scope :became_invalid_between, lambda { |from, to|
  where(invalid_at: from..to)
}

#became_valid_between(from, to) ⇒ ActiveRecord::Relation

Returns facts that became valid within a date range

Parameters:

  • from (Date, Time)

    start of range

  • to (Date, Time)

    end of range

Returns:

  • (ActiveRecord::Relation)


103
104
105
# File 'lib/fact_db/models/fact.rb', line 103

scope :became_valid_between, lambda { |from, to|
  where(valid_at: from..to)
}

#by_extraction_method(method) ⇒ ActiveRecord::Relation

Alias for extracted_by

Parameters:

  • method (String, Symbol)

    extraction method

Returns:

  • (ActiveRecord::Relation)


153
# File 'lib/fact_db/models/fact.rb', line 153

scope :by_extraction_method, ->(method) { where(extraction_method: method) }

#canonicalActiveRecord::Relation

Returns facts with canonical status

Returns:

  • (ActiveRecord::Relation)


58
# File 'lib/fact_db/models/fact.rb', line 58

scope :canonical, -> { where(status: "canonical") }

#corroborating_factsActiveRecord::Relation

Returns facts that corroborate this one

Returns:

  • (ActiveRecord::Relation)

    corroborating facts



289
290
291
292
293
# File 'lib/fact_db/models/fact.rb', line 289

def corroborating_facts
  return Fact.none unless corroborated_by_ids.any?

  Fact.where(id: corroborated_by_ids)
end

#currently_validActiveRecord::Relation

Returns facts that are currently valid (no invalid_at date)

Returns:

  • (ActiveRecord::Relation)


73
# File 'lib/fact_db/models/fact.rb', line 73

scope :currently_valid, -> { where(invalid_at: nil) }

#currently_valid?Boolean

Checks if the fact is currently valid

Returns:

  • (Boolean)

    true if the fact has no invalid_at date



170
171
172
# File 'lib/fact_db/models/fact.rb', line 170

def currently_valid?
  invalid_at.nil?
end

#durationActiveSupport::Duration?

Returns the duration the fact was valid

Returns:

  • (ActiveSupport::Duration, nil)

    duration or nil if still valid



185
186
187
188
189
# File 'lib/fact_db/models/fact.rb', line 185

def duration
  return nil if invalid_at.nil?

  invalid_at - valid_at
end

#duration_daysInteger?

Returns the duration in days the fact was valid

Returns:

  • (Integer, nil)

    number of days or nil if still valid



194
195
196
197
198
# File 'lib/fact_db/models/fact.rb', line 194

def duration_days
  return nil if invalid_at.nil?

  (invalid_at.to_date - valid_at.to_date).to_i
end

#evidence_chainArray<FactDb::Models::Source>

Returns the complete evidence chain back to original sources

Recursively traces through synthesized facts to find all original sources.

Returns:



300
301
302
303
304
305
306
307
308
309
310
# File 'lib/fact_db/models/fact.rb', line 300

def evidence_chain
  evidence = sources.to_a

  if synthesized? && derived_from_ids.any?
    source_facts.each do |source_fact|
      evidence.concat(source_fact.evidence_chain)
    end
  end

  evidence.uniq
end

#extracted_by(method) ⇒ ActiveRecord::Relation

Returns facts extracted by a specific method

Parameters:

  • method (String, Symbol)

    extraction method (manual, llm, rule_based)

Returns:

  • (ActiveRecord::Relation)


147
# File 'lib/fact_db/models/fact.rb', line 147

scope :extracted_by, ->(method) { where(extraction_method: method) }

#high_confidenceActiveRecord::Relation

Returns facts with confidence >= 0.9

Returns:

  • (ActiveRecord::Relation)


158
# File 'lib/fact_db/models/fact.rb', line 158

scope :high_confidence, -> { where("confidence >= ?", 0.9) }

#historicalActiveRecord::Relation

Returns facts that have been invalidated

Returns:

  • (ActiveRecord::Relation)


78
# File 'lib/fact_db/models/fact.rb', line 78

scope :historical, -> { where.not(invalid_at: nil) }

#invalidate!(at: Time.current) ⇒ Boolean

Invalidates this fact at a specific time

Parameters:

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

    when the fact became invalid (defaults to now)

Returns:

  • (Boolean)

    true if update succeeded



218
219
220
# File 'lib/fact_db/models/fact.rb', line 218

def invalidate!(at: Time.current)
  update!(invalid_at: at)
end

#low_confidenceActiveRecord::Relation

Returns facts with confidence < 0.5

Returns:

  • (ActiveRecord::Relation)


163
# File 'lib/fact_db/models/fact.rb', line 163

scope :low_confidence, -> { where("confidence < ?", 0.5) }

#mentioning_entity(entity_id) ⇒ ActiveRecord::Relation

Returns facts that mention a specific entity

Parameters:

  • entity_id (Integer)

    the entity ID

Returns:

  • (ActiveRecord::Relation)


120
121
122
# File 'lib/fact_db/models/fact.rb', line 120

scope :mentioning_entity, lambda { |entity_id|
  joins(:entity_mentions).where(fact_db_entity_mentions: { entity_id: entity_id }).distinct
}

#prove_itHash?

Returns the original source lines from which this fact was derived

Uses line metadata to extract the relevant section from the source document and highlights lines containing key terms from the fact.

Examples:

fact.prove_it
# => {
#   full_section: "...",
#   focused_lines: "John joined Acme Corp...",
#   focused_line_numbers: [15, 16],
#   key_terms: ["John", "Acme Corp"]
# }

Returns:

  • (Hash, nil)

    hash with :full_section, :focused_lines, :focused_line_numbers, :key_terms or nil if source/line metadata unavailable



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/fact_db/models/fact.rb', line 328

def prove_it
  source = fact_sources.first&.source
  return nil unless source&.content

  line_start = &.dig("line_start")
  line_end = &.dig("line_end")
  return nil unless line_start && line_end

  lines = source.content.lines
  start_idx = line_start.to_i - 1
  end_idx = line_end.to_i - 1

  return nil if start_idx < 0 || end_idx >= lines.length

  section_lines = lines[start_idx..end_idx]
  full_section = section_lines.join

  # Find focused lines by matching key terms from fact
  key_terms = extract_key_terms
  scored_lines = score_lines_by_relevance(section_lines, key_terms, start_idx)

  # Return lines that have at least one match, sorted by line number
  relevant = scored_lines.select { |l| l[:score] > 0 }
                         .sort_by { |l| l[:line_number] }

  {
    full_section: full_section,
    focused_lines: relevant.map { |l| l[:text] }.join,
    focused_line_numbers: relevant.map { |l| l[:line_number] },
    key_terms: key_terms
  }
end

#search_text(query) ⇒ ActiveRecord::Relation

Full-text search on fact text using PostgreSQL tsvector

Parameters:

  • query (String)

    the search query

Returns:

  • (ActiveRecord::Relation)


139
140
141
# File 'lib/fact_db/models/fact.rb', line 139

scope :search_text, lambda { |query|
  where("to_tsvector('english', text) @@ plainto_tsquery('english', ?)", query)
}

#source_factsActiveRecord::Relation

Returns the source facts for synthesized facts

Returns:

  • (ActiveRecord::Relation)

    facts this one was derived from



280
281
282
283
284
# File 'lib/fact_db/models/fact.rb', line 280

def source_facts
  return Fact.none unless derived_from_ids.any?

  Fact.where(id: derived_from_ids)
end

#supersede_with!(new_text, valid_at:) ⇒ FactDb::Models::Fact

Supersedes this fact with new information

Creates a new canonical fact and marks this one as superseded.

Parameters:

  • new_text (String)

    the updated fact text

  • valid_at (Date, Time)

    when the new fact became valid

Returns:



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/fact_db/models/fact.rb', line 229

def supersede_with!(new_text, valid_at:)
  transaction do
    new_fact = self.class.create!(
      text: new_text,
      valid_at: valid_at,
      status: "canonical",
      extraction_method: extraction_method
    )

    update!(
      status: "superseded",
      superseded_by_id: new_fact.id,
      invalid_at: valid_at
    )

    new_fact
  end
end

#supersededActiveRecord::Relation

Returns facts that have been superseded

Returns:

  • (ActiveRecord::Relation)


63
# File 'lib/fact_db/models/fact.rb', line 63

scope :superseded, -> { where(status: "superseded") }

#superseded?Boolean

Checks if this fact has been superseded

Returns:

  • (Boolean)

    true if status is “superseded”



203
204
205
# File 'lib/fact_db/models/fact.rb', line 203

def superseded?
  status == "superseded"
end

#synthesizedActiveRecord::Relation

Returns facts that were synthesized from other facts

Returns:

  • (ActiveRecord::Relation)


68
# File 'lib/fact_db/models/fact.rb', line 68

scope :synthesized, -> { where(status: "synthesized") }

#synthesized?Boolean

Checks if this fact was synthesized from other facts

Returns:

  • (Boolean)

    true if status is “synthesized”



210
211
212
# File 'lib/fact_db/models/fact.rb', line 210

def synthesized?
  status == "synthesized"
end

#valid_at(date) ⇒ ActiveRecord::Relation

Returns facts valid at a specific point in time

Parameters:

  • date (Date, Time)

    the point in time

Returns:

  • (ActiveRecord::Relation)


84
85
86
87
# File 'lib/fact_db/models/fact.rb', line 84

scope :valid_at, lambda { |date|
  where("valid_at <= ?", date)
    .where("invalid_at > ? OR invalid_at IS NULL", date)
}

#valid_at?(date) ⇒ Boolean

Checks if the fact was valid at a specific date

Parameters:

  • date (Date, Time)

    the point in time to check

Returns:

  • (Boolean)

    true if the fact was valid at the given date



178
179
180
# File 'lib/fact_db/models/fact.rb', line 178

def valid_at?(date)
  valid_at <= date && (invalid_at.nil? || invalid_at > date)
end

#valid_between(from, to) ⇒ ActiveRecord::Relation

Returns facts valid during a date range

Parameters:

  • from (Date, Time)

    start of range

  • to (Date, Time)

    end of range

Returns:

  • (ActiveRecord::Relation)


94
95
96
# File 'lib/fact_db/models/fact.rb', line 94

scope :valid_between, lambda { |from, to|
  where("valid_at <= ? AND (invalid_at > ? OR invalid_at IS NULL)", to, from)
}

#with_role(entity_id, role) ⇒ ActiveRecord::Relation

Returns facts where an entity has a specific role

Parameters:

  • entity_id (Integer)

    the entity ID

  • role (String, Symbol)

    the mention role (subject, object, etc.)

Returns:

  • (ActiveRecord::Relation)


129
130
131
132
133
# File 'lib/fact_db/models/fact.rb', line 129

scope :with_role, lambda { |entity_id, role|
  joins(:entity_mentions).where(
    fact_db_entity_mentions: { entity_id: entity_id, mention_role: role }
  ).distinct
}