The @ operator lets you access a past version. The .history property gives you the full timeline. But the most common temporal question is not "what was the value?" -- it is "did the value change?" Developers do not want raw history. They want answers: Did the price go up? By how much? What percentage? Was it ever at a specific value?
Sessions 083 through 088 implemented six native helper functions that turn FLIN's temporal model from a storage mechanism into an analytical tool. These functions -- field_changed, calculate_delta, percent_change, changed_from, value_changed, and field_history -- answer the questions that applications actually ask.
The Pattern We Kept Writing
Before the comparison helpers existed, detecting a field change required a recurring code pattern:
old_price = (product @ -1).price
new_price = product.price
price_changed = old_price != new_price
delta = new_price - old_price
pct = (delta / old_price) * 100Five lines for a single comparison. Multiply that by every field you want to monitor on every entity in your application, and the boilerplate becomes overwhelming. Worse, this pattern has edge cases: What if there is no previous version? What if the old value is zero (division by zero in percent change)? What if the field holds a string instead of a number?
The comparison helpers encapsulate this logic into single function calls that handle all edge cases correctly.
The Six Functions
1. field_changed(entity, field_name) -- Boolean Change Detection
The most basic temporal question: "Did this field change since the last version?"
entity Product {
name: text
price: float
stock: int
}product = Product { name: "Widget", price: 50.00, stock: 100 } save product
product.price = 55.00 save product
field_changed(product, "price") // true field_changed(product, "name") // false field_changed(product, "stock") // false ```
The implementation retrieves the previous version using find_at_version(version - 1), extracts the named field from both current and previous versions, and compares them using the existing values_equal() method.
fn native_field_changed(&mut self) -> VMResult<()> {
let field_name_val = self.pop()?;
let entity_val = self.pop()?;let field_name = match &field_name_val { Value::Object(id) => self.get_string(*id)?.to_string(), Value::Text(s) => s.clone(), _ => return Err(RuntimeError::TypeError { / ... / }), };
// Get current field value let current_value = get_entity_field(&entity_val, &field_name);
// Get previous version's field value let previous = self.find_at_version(&type_name, entity_id, version - 1); let previous_value = previous .map(|v| v.fields.get(&field_name).cloned()) .flatten();
// Compare let changed = !self.values_equal_opt(¤t_value, &previous_value); self.push(Value::Bool(changed)); Ok(()) } ```
One design challenge was text parameter extraction. In FLIN's VM, text values can be either Value::Object (a heap-allocated string) or Value::Text (an inline short string). The function must handle both, following the pattern established by native_url_encode():
let field_name = match &field_name_val {
Value::Object(id) => self.get_string(*id)?.to_string(),
Value::Text(s) => s.clone(),
_ => return Err(RuntimeError::TypeError {
expected: "text".to_string(),
found: format!("{:?}", field_name_val),
}),
};This dual-path pattern was applied to all three functions that accept text parameters: field_changed, changed_from, and field_history.
2. calculate_delta(old, new) -- Numeric Difference
Computes the arithmetic difference between two values. Handles integer/integer, float/float, and mixed-type comparisons.
old_price = (product @ -1).price // 50.00
new_price = product.price // 55.00delta = calculate_delta(old_price, new_price) // 5.00 ```
The return type depends on the input types: if both are integers, the result is an integer. If either is a float, the result is a float. This preserves type precision -- an integer delta of 5 is more useful than a float delta of 5.0 when both inputs are integers.
fn native_calculate_delta(&mut self) -> VMResult<()> {
let new_val = self.pop()?;
let old_val = self.pop()?;let result = match (&old_val, &new_val) { (Value::Int(old), Value::Int(new)) => Value::Int(new - old), (Value::Float(old), Value::Float(new)) => Value::Float(new - old), (Value::Int(old), Value::Float(new)) => Value::Float(new - *old as f64), (Value::Float(old), Value::Int(new)) => Value::Float(*new as f64 - old), _ => return Err(RuntimeError::TypeError { / ... / }), };
self.push(result); Ok(()) } ```
3. percent_change(old, new) -- Percentage Difference
Calculates ((new - old) / old) * 100. Always returns a float. Handles the division-by-zero edge case: if the old value is zero and the new value is also zero, the result is zero percent. If the old value is zero and the new value is non-zero, the function returns an error rather than infinity.
pct = percent_change(50.00, 55.00) // 10.0
pct = percent_change(100, 90) // -10.0
pct = percent_change(0.0, 0.0) // 0.04. changed_from(entity, field_name, expected_value) -- Previous Value Check
A targeted question: "Was the previous value of this field equal to X?" Useful for detecting specific transitions.
// Did the price just change from $55?
changed_from(product, "price", 55.00) // true// Did the stock just change from 100? changed_from(product, "stock", 100) // false (stock unchanged) ```
The implementation finds the previous version, extracts the named field, and compares it against the expected value using values_equal().
5. value_changed(entity, field_name) -- Alias for field_changed
An alias that provides a more natural reading for certain contexts. value_changed(product, "price") reads as "did the value change?" while field_changed(product, "price") reads as "did the field change?" Both do exactly the same thing.
fn native_value_changed(&mut self) -> VMResult<()> {
self.native_field_changed() // Direct delegation
}6. field_history(entity, field_name) -- Single-Field Timeline
Returns a list of all historical values for a specific field, extracted from the full version history. This is more efficient than .history when you only care about one field.
prices = field_history(product, "price")
// [50.00, 55.00, 55.00, 60.00]stocks = field_history(product, "stock") // [100, 100, 90, 85] ```
The implementation retrieves the full history via database.get_history(), iterates through each version, extracts the named field, and builds a list. The current version's value is appended at the end.
fn native_field_history(&mut self) -> VMResult<()> {
let field_name = extract_text(&field_name_val)?;
let entity = extract_entity(&entity_val)?;let history = self.database .get_history(&entity.entity_type, entity.id) .unwrap_or_default();
let mut values = Vec::new(); for version in history { if let Some(val) = version.fields.get(&field_name) { values.push(val.clone()); } }
// Add current value if let Some(val) = entity.fields.get(&field_name) { values.push(val.clone()); }
self.push(create_list(values)); Ok(()) } ```
Registration and Type Checking
All six functions were registered as native built-ins in the VM with consecutive indices:
register(self, "field_changed", 2, 61);
register(self, "calculate_delta", 2, 62);
register(self, "percent_change", 2, 63);
register(self, "changed_from", 3, 64);
register(self, "value_changed", 2, 65);
register(self, "field_history", 2, 66);And typed in the type checker:
"field_changed" => FlinType::Function {
params: vec![FlinType::Unknown, FlinType::Text],
ret: Box::new(FlinType::Bool),
min_arity: 2,
has_rest: false,
}"percent_change" => FlinType::Function { params: vec![FlinType::Unknown, FlinType::Unknown], ret: Box::new(FlinType::Float), min_arity: 2, has_rest: false, }
"field_history" => FlinType::Function { params: vec![FlinType::Unknown, FlinType::Text], ret: Box::new(FlinType::List(Box::new(FlinType::Unknown))), min_arity: 2, has_rest: false, } ```
The Unknown parameter type is used for entities because the comparison functions work on any entity type. The type checker validates the function signature at call sites but does not restrict which entity types can be passed.
A Complete Example
Here is a real-world pattern that combines multiple comparison helpers to build a product analytics dashboard:
entity Product {
name: text
price: float
stock: int
}product = Product { name: "Widget", price: 50.00, stock: 100 } save product
product.price = 55.00 save product
product.stock = 90 save product
product.price = 60.00 product.stock = 85 save product
// Change detection price_changed = field_changed(product, "price") // true name_changed = field_changed(product, "name") // false
// Delta calculation old_price = (product @ -1).price new_price = product.price delta = calculate_delta(old_price, new_price) // 5.0 pct = percent_change(old_price, new_price) // 9.09
// Historical analysis price_timeline = field_history(product, "price") // [50, 55, 55, 60] stock_timeline = field_history(product, "stock") // [100, 100, 90, 85]
// View integration
Product Analytics
Price
${new_price} {if price_changed} Changed: +${delta} ({pct}%) {else} No change {/if}
Price History
{for price in price_timeline} ${price} {/for}This dashboard would require a price history table, a change tracking system, and percentage calculation logic in a traditional framework. In FLIN, it is a single page component with no additional infrastructure.
Why Native Functions Instead of Library Functions
We could have implemented these helpers as FLIN library functions rather than native VM functions. A library approach would use FLIN's own syntax:
fn field_changed(entity, field_name: text) -> bool {
old = entity @ -1
if old {
return entity[field_name] != old[field_name]
}
return false
}We chose native implementation for three reasons:
Performance. Native functions execute in Rust, directly accessing the VM's internal data structures. A library function would need to go through the bytecode interpreter, with each @ access and field lookup generating multiple opcode dispatches. For comparison helpers called frequently in loops (iterating over history to detect changes), the performance difference is meaningful.
Edge case handling. The native implementation can access entity internals that FLIN code cannot: the raw version number, the database's history storage, and the VM's heap. This enables correct handling of edge cases like unsaved entities, destroyed entities, and entities with only one version.
Error messages. Native functions can produce precise error messages referencing the actual types and values involved, rather than generic FLIN runtime errors.
Issues Encountered
Duplicate Method Name
The first implementation attempt created a values_equal() method for comparing Option types. But a values_equal(&Value, &Value) method already existed in the VM. The compiler rejected the duplicate. The fix was renaming the new method to values_equal_opt() and having it delegate to the existing method for unwrapped values.
Stack Argument Order
FLIN's VM pushes function arguments left-to-right but pops them right-to-left. For changed_from(entity, field, value), the pop order is: value first, field second, entity third. Getting this wrong produces subtle bugs where the entity is treated as the value and vice versa -- no type error, just wrong results.
Impact on the Temporal Model
Session 083 brought TEMP-6 (Temporal Comparisons) from twenty percent to one hundred percent. Combined with earlier sessions, this was the seventh category completed:
| Completed Category | Tasks |
|---|---|
| TEMP-1: Core Soft Delete | 5/5 |
| TEMP-2: Temporal Access | 18/18 |
| TEMP-3: Temporal Keywords | 14/14 |
| TEMP-4: History Queries | 22/22 |
| TEMP-5: Time Arithmetic | 12/12 |
| TEMP-6: Temporal Comparisons | 10/10 |
| TEMP-11: Integration Tests | 27/27 |
Overall progress: one hundred twenty-one out of one hundred sixty tasks (seventy-five point six percent). The temporal model was three-quarters complete.
The comparison helpers were the last piece needed to make FLIN's temporal model useful for real applications. Storage and access are infrastructure. Filtering and ordering are power features. But change detection, delta calculation, and percentage comparisons are the analytics layer that turns historical data into business intelligence.
Comparison with Other Approaches
In traditional web frameworks, temporal comparison logic is scattered across the application:
Rails:
# In the model
def price_changed?
previous_version = versions.last&.reify
return false unless previous_version
price != previous_version.price
enddef price_delta prev = versions.last&.reify&.price || 0 price - prev end
def price_percent_change prev = versions.last&.reify&.price return 0 unless prev && prev > 0 ((price - prev).to_f / prev * 100).round(2) end ```
Three methods per field, per model. For an entity with five trackable fields, that is fifteen methods -- plus unit tests for each.
Django:
def get_price_change(product):
history = product.history.order_by('-history_date')
if history.count() < 2:
return None
current = history[0]
previous = history[1]
return {
'changed': current.price != previous.price,
'delta': current.price - previous.price,
'percent': ((current.price - previous.price) / previous.price) * 100,
}A function that queries the history table, extracts two records, performs calculations, and returns a dictionary. The logic is correct but verbose, framework-specific, and must be written for every entity and field.
FLIN:
field_changed(product, "price")
calculate_delta(old_price, new_price)
percent_change(old_price, new_price)Three function calls. No model methods. No history table queries. No edge case handling. The functions work on any entity, any field, and handle all edge cases (missing versions, zero division, type coercion) internally.
The productivity difference is not incremental -- it is categorical. What takes dozens of lines in other frameworks takes one line in FLIN.
Five hundred and forty-six lines across three files. Six functions. Zero regressions. And FLIN's temporal model became not just a storage system, but an analytical engine.
---
This is Part 6 of the "How We Built FLIN" temporal model series, documenting the comparison helper functions that turn temporal data into analytical insights.
Series Navigation: - [046] Every Entity Remembers Everything: The Temporal Model - [047] Version History and Time Travel Queries - [048] Temporal Integration: From Bugs to 100% Test Coverage - [049] Destroy and Restore: Soft Deletes Done Right - [050] Temporal Filtering and Ordering - [051] Temporal Comparison Helpers (you are here) - [052] Version Metadata Access - [053] Time Arithmetic: Adding Days, Comparing Dates - [054] Tracking Accuracy and Validation - [055] The Temporal Model Complete: What No Other Language Has