///|
/// Feature-store contracts, point-in-time lookup, and freshness governance.
pub enum FeatureValueKind {
Numeric
Boolean
Text
Vector
} derive(Debug, Eq, ToJson, FromJson)
///|
pub struct FeatureDefinition {
name : String
kind : FeatureValueKind
entity : String
description : String
default_value : Double
ttl_seconds : Int
owner : String
tags : Array[String]
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn FeatureDefinition::new(
name : String,
entity : String,
kind : FeatureValueKind,
) -> FeatureDefinition {
if name == "" || entity == "" {
abort("FeatureDefinition: name and entity are required")
}
{
name,
kind,
entity,
description: "",
default_value: 0.0,
ttl_seconds: 0,
owner: "",
tags: [],
}
}
///|
pub fn FeatureDefinition::with_ttl(
self : FeatureDefinition,
ttl_seconds : Int,
) -> FeatureDefinition {
if ttl_seconds < 0 {
abort("FeatureDefinition: ttl must be non-negative")
}
{ ..self, ttl_seconds, }
}
///|
pub fn FeatureDefinition::with_default(
self : FeatureDefinition,
default_value : Double,
) -> FeatureDefinition {
{ ..self, default_value, }
}
///|
pub fn FeatureDefinition::tagged(
self : FeatureDefinition,
tag : String,
) -> FeatureDefinition {
let tags = self.tags.copy()
if !tags.contains(tag) {
tags.push(tag)
}
{ ..self, tags, }
}
///|
pub struct FeatureObservation {
entity_id : String
feature : String
value : Double
event_time : Int
ingest_time : Int
source : String
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn FeatureObservation::new(
entity_id : String,
feature : String,
value : Double,
event_time : Int,
) -> FeatureObservation {
{ entity_id, feature, value, event_time, ingest_time: event_time, source: "" }
}
///|
pub fn FeatureObservation::with_ingest_time(
self : FeatureObservation,
ingest_time : Int,
) -> FeatureObservation {
{ ..self, ingest_time, }
}
///|
pub fn FeatureObservation::is_late(self : FeatureObservation) -> Bool {
self.ingest_time > self.event_time
}
///|
pub fn FeatureObservation::age(self : FeatureObservation, now : Int) -> Int {
(now - self.event_time).max(0)
}
///|
pub struct FeatureSnapshot {
entity_id : String
as_of : Int
values : Array[(String, Double)]
missing : Array[String]
late_count : Int
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn FeatureSnapshot::new(entity_id : String, as_of : Int) -> FeatureSnapshot {
{ entity_id, as_of, values: [], missing: [], late_count: 0 }
}
///|
pub fn FeatureSnapshot::get(self : FeatureSnapshot, feature : String) -> Double {
for item in self.values.iter() {
if item.0 == feature {
return item.1
}
}
0.0
}
///|
pub fn FeatureSnapshot::has(self : FeatureSnapshot, feature : String) -> Bool {
self.values.any(fn(item) { item.0 == feature })
}
///|
pub fn FeatureSnapshot::vector(
self : FeatureSnapshot,
definitions : Array[FeatureDefinition],
) -> Array[Double] {
definitions.map(fn(definition) { self.get(definition.name) })
}
///|
pub struct FeatureStore {
definitions : Array[FeatureDefinition]
observations : Array[FeatureObservation]
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn FeatureStore::new() -> FeatureStore {
{ definitions: [], observations: [] }
}
///|
pub fn FeatureStore::register(
self : FeatureStore,
definition : FeatureDefinition,
) -> FeatureStore {
if self.definitions.any(fn(item) { item.name == definition.name }) {
abort("FeatureStore: feature already registered")
}
let definitions = self.definitions.copy()
definitions.push(definition)
{ ..self, definitions, }
}
///|
pub fn FeatureStore::definition(
self : FeatureStore,
name : String,
) -> FeatureDefinition {
for definition in self.definitions.iter() {
if definition.name == name {
return definition
}
}
abort("FeatureStore: definition not found")
}
///|
pub fn FeatureStore::write(
self : FeatureStore,
observation : FeatureObservation,
) -> FeatureStore {
let _ = self.definition(observation.feature)
let observations = self.observations.copy()
observations.push(observation)
{ ..self, observations, }
}
///|
pub fn FeatureStore::write_batch(
self : FeatureStore,
observations : Array[FeatureObservation],
) -> FeatureStore {
let mut result = self
for observation in observations.iter() {
result = result.write(observation)
}
result
}
///|
pub fn FeatureStore::lookup(
self : FeatureStore,
entity_id : String,
feature_names : Array[String],
as_of : Int,
) -> FeatureSnapshot {
let values : Array[(String, Double)] = []
let missing : Array[String] = []
let mut late_count = 0
for name in feature_names.iter() {
let definition = self.definition(name)
let mut found = false
let mut best_time = -1
let mut best_value = definition.default_value
for observation in self.observations.iter() {
if observation.entity_id == entity_id &&
observation.feature == name &&
observation.event_time <= as_of &&
observation.event_time >= best_time {
found = true
best_time = observation.event_time
best_value = observation.value
if observation.is_late() {
late_count = late_count + 1
}
}
}
if found {
values.push((name, best_value))
} else {
missing.push(name)
values.push((name, definition.default_value))
}
}
{ entity_id, as_of, values, missing, late_count }
}
///|
pub fn FeatureStore::batch_lookup(
self : FeatureStore,
entity_ids : Array[String],
feature_names : Array[String],
as_of : Int,
) -> Array[FeatureSnapshot] {
entity_ids.map(fn(entity_id) { self.lookup(entity_id, feature_names, as_of) })
}
///|
pub fn FeatureStore::observations_for(
self : FeatureStore,
entity_id : String,
feature : String,
) -> Array[FeatureObservation] {
self.observations.filter(fn(item) {
item.entity_id == entity_id && item.feature == feature
})
}
///|
pub fn FeatureStore::freshness(
self : FeatureStore,
feature : String,
now : Int,
) -> Double {
let definition = self.definition(feature)
let values = self.observations.filter(fn(item) {
item.feature == feature && item.event_time <= now
})
if values.length() == 0 || definition.ttl_seconds == 0 {
return 1.0
}
let latest = values.fold(init=values[0].event_time, (current, item) => {
current.max(item.event_time)
})
let age = (now - latest).max(0)
(1.0 - age.to_double() / definition.ttl_seconds.to_double()).max(0.0)
}
///|
pub struct FeatureFreshness {
feature : String
score : Double
latest_event : Int
stale : Bool
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn FeatureStore::freshness_report(
self : FeatureStore,
now : Int,
) -> Array[FeatureFreshness] {
let result : Array[FeatureFreshness] = []
for definition in self.definitions.iter() {
let values = self.observations.filter(fn(item) {
item.feature == definition.name && item.event_time <= now
})
let latest = if values.length() == 0 {
-1
} else {
values.fold(init=values[0].event_time, (current, item) => {
current.max(item.event_time)
})
}
let score = self.freshness(definition.name, now)
result.push({
feature: definition.name,
score,
latest_event: latest,
stale: score < 0.5,
})
}
result
}
///|
pub struct FeatureDrift {
feature : String
baseline_mean : Double
current_mean : Double
standardized_shift : Double
population_stability : Double
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn feature_drift(
feature : String,
baseline : Array[Double],
current : Array[Double],
) -> FeatureDrift {
if baseline.length() == 0 || current.length() == 0 {
abort("feature_drift: empty sample")
}
let base_mean = mean(baseline)
let current_mean = mean(current)
let scale = variance(baseline).sqrt().max(0.000000000001)
let psi = (current_mean - base_mean).abs() / scale
{
feature,
baseline_mean: base_mean,
current_mean,
standardized_shift: (current_mean - base_mean) / scale,
population_stability: psi,
}
}
///|
pub struct FeatureStoreHealth {
definitions : Int
observations : Int
missing_rate : Double
stale_rate : Double
drift_rate : Double
score : Double
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn feature_store_health(
store : FeatureStore,
now : Int,
) -> FeatureStoreHealth {
let reports = store.freshness_report(now)
let stale = reports.filter(fn(item) { item.stale }).length()
let mut missing = 0
let mut lookups = 0
for observation in store.observations.iter() {
if observation.value != observation.value {
missing = missing + 1
}
lookups = lookups + 1
}
let missing_rate = if lookups == 0 {
0.0
} else {
missing.to_double() / lookups.to_double()
}
let stale_rate = if reports.length() == 0 {
0.0
} else {
stale.to_double() / reports.length().to_double()
}
{
definitions: store.definitions.length(),
observations: store.observations.length(),
missing_rate,
stale_rate,
drift_rate: 0.0,
score: (1.0 - missing_rate - stale_rate).max(0.0),
}
}
///|
pub enum MaterializationMode {
Online
Offline
Hybrid
} derive(Debug, Eq, ToJson, FromJson)
///|
pub struct MaterializationJob {
feature : String
mode : MaterializationMode
start_time : Int
end_time : Int
rows_written : Int
status : PipelineStatus
} derive(Debug, Eq, ToJson, FromJson)
///|
pub fn MaterializationJob::new(
feature : String,
mode : MaterializationMode,
start_time : Int,
end_time : Int,
) -> MaterializationJob {
if end_time < start_time {
abort("MaterializationJob: invalid time range")
}
{
feature,
mode,
start_time,
end_time,
rows_written: 0,
status: PipelineStatus::Pending,
}
}
///|
pub fn MaterializationJob::complete(
self : MaterializationJob,
rows : Int,
) -> MaterializationJob {
{ ..self, rows_written: rows.max(0), status: PipelineStatus::Succeeded }
}
///|
pub fn MaterializationJob::fail(
self : MaterializationJob,
) -> MaterializationJob {
{ ..self, status: PipelineStatus::Failed }
}
///|
pub fn materialize(
store : FeatureStore,
feature : String,
entity_ids : Array[String],
start_time : Int,
end_time : Int,
) -> (FeatureStore, MaterializationJob) {
let job = MaterializationJob::new(
feature,
MaterializationMode::Offline,
start_time,
end_time,
)
let result = store
let mut rows = 0
for entity in entity_ids.iter() {
let observations = result.observations_for(entity, feature)
for observation in observations.iter() {
if observation.event_time >= start_time &&
observation.event_time <= end_time {
rows = rows + 1
}
}
}
(result, job.complete(rows))
}