L'opérateur @ permet d'accéder à une version passée. La propriété .history donne la chronologie complète. Mais la question temporelle la plus courante n'est pas « quelle était la valeur ? » -- c'est « la valeur a-t-elle changé ? » Les développeurs ne veulent pas l'historique brut. Ils veulent des réponses : le prix a-t-il augmenté ? De combien ? Quel pourcentage ? La valeur a-t-elle déjà été à un niveau précis ?
Les sessions 083 à 088 ont implémenté six fonctions natives qui transforment le modèle temporel de FLIN d'un mécanisme de stockage en un outil analytique. Ces fonctions -- field_changed, calculate_delta, percent_change, changed_from, value_changed et field_history -- répondent aux questions que les applications posent réellement.
Le pattern que nous écrivions sans cesse
Avant l'existence des fonctions de comparaison, détecter un changement de champ nécessitait un pattern récurrent :
flinold_price = (product @ -1).price
new_price = product.price
price_changed = old_price != new_price
delta = new_price - old_price
pct = (delta / old_price) * 100Cinq lignes pour une seule comparaison. Multipliez cela par chaque champ à surveiller sur chaque entité de votre application, et le code répétitif devient écrasant. Pire, ce pattern présente des cas limites : et s'il n'y a pas de version précédente ? Et si l'ancienne valeur est zéro (division par zéro dans le calcul de pourcentage) ? Et si le champ contient du texte au lieu d'un nombre ?
Les fonctions de comparaison encapsulent cette logique en appels uniques qui gèrent correctement tous les cas limites.
Les six fonctions
1. field_changed(entity, field_name) -- Détection booléenne de changement
La question temporelle la plus basique : « Ce champ a-t-il changé depuis la dernière version ? »
flinentity 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") // falseL'implémentation récupère la version précédente via find_at_version(version - 1), extrait le champ nommé des deux versions (actuelle et précédente), et les compare en utilisant la méthode existante values_equal().
rustfn 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(())
}Un défi de conception était l'extraction du paramètre texte. Dans la VM de FLIN, les valeurs texte peuvent être soit Value::Object (une chaîne allouée sur le tas) soit Value::Text (une courte chaîne inline). La fonction doit gérer les deux cas, suivant le pattern établi par native_url_encode() :
rustlet 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),
}),
};Ce pattern à double chemin a été appliqué aux trois fonctions qui acceptent des paramètres texte : field_changed, changed_from et field_history.
2. calculate_delta(old, new) -- Différence numérique
Calcule la différence arithmétique entre deux valeurs. Gère les comparaisons entier/entier, float/float et types mixtes.
flinold_price = (product @ -1).price // 50.00
new_price = product.price // 55.00
delta = calculate_delta(old_price, new_price) // 5.00Le type de retour dépend des types d'entrée : si les deux sont des entiers, le résultat est un entier. Si l'un est un float, le résultat est un float. Cela préserve la précision des types -- un delta entier de 5 est plus utile qu'un delta float de 5.0 lorsque les deux entrées sont des entiers.
rustfn 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) -- Différence en pourcentage
Calcule ((new - old) / old) * 100. Retourne toujours un float. Gère le cas limite de la division par zéro : si l'ancienne valeur est zéro et la nouvelle aussi, le résultat est zéro pour cent. Si l'ancienne valeur est zéro et la nouvelle ne l'est pas, la fonction retourne une erreur plutôt que l'infini.
flinpct = 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) -- Vérification de la valeur précédente
Une question ciblée : « La valeur précédente de ce champ était-elle égale à X ? » Utile pour détecter des transitions spécifiques.
flin// Le prix vient-il de changer depuis 55 $ ?
changed_from(product, "price", 55.00) // true
// Le stock vient-il de changer depuis 100 ?
changed_from(product, "stock", 100) // false (stock inchangé)L'implémentation trouve la version précédente, extrait le champ nommé et le compare à la valeur attendue en utilisant values_equal().
5. value_changed(entity, field_name) -- Alias de field_changed
Un alias qui offre une lecture plus naturelle dans certains contextes. value_changed(product, "price") se lit comme « la valeur a-t-elle changé ? » tandis que field_changed(product, "price") se lit comme « le champ a-t-il changé ? » Les deux font exactement la même chose.
rustfn native_value_changed(&mut self) -> VMResult<()> {
self.native_field_changed() // Direct delegation
}6. field_history(entity, field_name) -- Chronologie d'un seul champ
Retourne une liste de toutes les valeurs historiques d'un champ spécifique, extraites de l'historique complet des versions. C'est plus efficace que .history lorsque vous ne vous intéressez qu'à un seul champ.
flinprices = field_history(product, "price")
// [50.00, 55.00, 55.00, 60.00]
stocks = field_history(product, "stock")
// [100, 100, 90, 85]L'implémentation récupère l'historique complet via database.get_history(), itère chaque version, extrait le champ nommé et construit une liste. La valeur de la version actuelle est ajoutée à la fin.
rustfn 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(())
}Enregistrement et vérification des types
Les six fonctions ont été enregistrées comme fonctions natives dans la VM avec des indices consécutifs :
rustregister(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);Et typées dans le vérificateur de types :
rust"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,
}Le type de paramètre Unknown est utilisé pour les entités car les fonctions de comparaison fonctionnent avec n'importe quel type d'entité. Le vérificateur de types valide la signature de la fonction aux sites d'appel mais ne restreint pas les types d'entités qui peuvent être passés.
Un exemple complet
Voici un pattern réel qui combine plusieurs fonctions de comparaison pour construire un tableau de bord analytique de produits :
flinentity 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
// Détection de changements
price_changed = field_changed(product, "price") // true
name_changed = field_changed(product, "name") // false
// Calcul de delta
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
// Analyse historique
price_timeline = field_history(product, "price") // [50, 55, 55, 60]
stock_timeline = field_history(product, "stock") // [100, 100, 90, 85]
// Intégration dans la vue
<div class="analytics-panel">
<h2>Analytique produit</h2>
<div class="metric">
<h3>Prix</h3>
<span class="value">${new_price}</span>
{if price_changed}
<span class="badge green">
Modifié : +${delta} ({pct} %)
</span>
{else}
<span class="badge gray">Aucun changement</span>
{/if}
</div>
<div class="history">
<h3>Historique des prix</h3>
{for price in price_timeline}
<span class="history-point">${price}</span>
{/for}
</div>
</div>Ce tableau de bord nécessiterait une table d'historique des prix, un système de suivi des changements et une logique de calcul de pourcentage dans un framework traditionnel. En FLIN, c'est un simple composant de page sans infrastructure supplémentaire.
Pourquoi des fonctions natives plutôt que des fonctions de bibliothèque
Nous aurions pu implémenter ces fonctions comme des fonctions de bibliothèque FLIN plutôt que comme des fonctions natives de la VM. Une approche bibliothèque utiliserait la propre syntaxe de FLIN :
flinfn field_changed(entity, field_name: text) -> bool {
old = entity @ -1
if old {
return entity[field_name] != old[field_name]
}
return false
}Nous avons choisi l'implémentation native pour trois raisons :
Performance. Les fonctions natives s'exécutent en Rust, accédant directement aux structures de données internes de la VM. Une fonction de bibliothèque devrait passer par l'interpréteur de bytecode, chaque accès @ et chaque consultation de champ générant plusieurs dispatches d'opcodes. Pour des fonctions de comparaison appelées fréquemment dans des boucles (itérer sur l'historique pour détecter des changements), la différence de performance est significative.
Gestion des cas limites. L'implémentation native peut accéder aux internes de l'entité que le code FLIN ne peut pas atteindre : le numéro de version brut, le stockage d'historique de la base de données et le tas de la VM. Cela permet la gestion correcte de cas limites comme les entités non sauvegardées, les entités détruites et les entités avec une seule version.
Messages d'erreur. Les fonctions natives peuvent produire des messages d'erreur précis référençant les types et valeurs réels impliqués, plutôt que des erreurs d'exécution FLIN génériques.
Problèmes rencontrés
Nom de méthode dupliqué
La première tentative d'implémentation a créé une méthode values_equal() pour comparer les types Option<Value>. Mais une méthode values_equal(&Value, &Value) existait déjà dans la VM. Le compilateur a rejeté le doublon. La correction a consisté à renommer la nouvelle méthode en values_equal_opt() et à la faire déléguer à la méthode existante pour les valeurs déballées.
Ordre des arguments sur la pile
La VM de FLIN pousse les arguments de fonction de gauche à droite mais les dépile de droite à gauche. Pour changed_from(entity, field, value), l'ordre de dépilement est : value en premier, field en deuxième, entity en troisième. Se tromper produit des bugs subtils où l'entité est traitée comme la valeur et vice versa -- pas d'erreur de type, juste des résultats incorrects.
Impact sur le modèle temporel
La session 083 a fait passer TEMP-6 (Comparaisons temporelles) de vingt pour cent à cent pour cent. Combiné aux sessions précédentes, c'était la septième catégorie complétée :
| Catégorie complétée | Tâches |
|---|---|
| TEMP-1 : Suppression douce de base | 5/5 |
| TEMP-2 : Accès temporel | 18/18 |
| TEMP-3 : Mots-clés temporels | 14/14 |
| TEMP-4 : Requêtes d'historique | 22/22 |
| TEMP-5 : Arithmétique temporelle | 12/12 |
| TEMP-6 : Comparaisons temporelles | 10/10 |
| TEMP-11 : Tests d'intégration | 27/27 |
Progression globale : cent vingt et un sur cent soixante tâches (soixante-quinze virgule six pour cent). Le modèle temporel était aux trois quarts complet.
Les fonctions de comparaison étaient la dernière pièce nécessaire pour rendre le modèle temporel de FLIN utile pour les applications réelles. Le stockage et l'accès sont de l'infrastructure. Le filtrage et le tri sont des fonctionnalités avancées. Mais la détection de changements, le calcul de delta et les comparaisons en pourcentage sont la couche analytique qui transforme les données historiques en intelligence métier.
Comparaison avec d'autres approches
Dans les frameworks web traditionnels, la logique de comparaison temporelle est dispersée dans l'application :
Rails :
ruby# In the model
def price_changed?
previous_version = versions.last&.reify
return false unless previous_version
price != previous_version.price
end
def 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)
endTrois méthodes par champ, par modèle. Pour une entité avec cinq champs à suivre, cela fait quinze méthodes -- plus les tests unitaires pour chacune.
Django :
pythondef 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,
}Une fonction qui interroge la table d'historique, extrait deux enregistrements, effectue des calculs et retourne un dictionnaire. La logique est correcte mais verbeuse, spécifique au framework, et doit être écrite pour chaque entité et chaque champ.
FLIN :
flinfield_changed(product, "price")
calculate_delta(old_price, new_price)
percent_change(old_price, new_price)Trois appels de fonction. Pas de méthodes de modèle. Pas de requêtes sur la table d'historique. Pas de gestion de cas limites. Les fonctions fonctionnent avec n'importe quelle entité, n'importe quel champ, et gèrent tous les cas limites (versions manquantes, division par zéro, coercition de types) en interne.
La différence de productivité n'est pas incrémentale -- elle est catégorielle. Ce qui prend des dizaines de lignes dans d'autres frameworks ne prend qu'une seule ligne en FLIN.
Cinq cent quarante-six lignes réparties sur trois fichiers. Six fonctions. Zéro régression. Et le modèle temporel de FLIN est passé d'un simple système de stockage à un moteur analytique.
Ceci est la partie 6 de la série « Comment nous avons construit FLIN » sur le modèle temporel, documentant les fonctions de comparaison qui transforment les données temporelles en analyses exploitables.
Navigation dans la série : - [046] Chaque entité se souvient de tout : le modèle temporel - [047] Historique des versions et requêtes de voyage dans le temps - [048] Intégration temporelle : des bugs à 100 % de couverture de tests - [049] Destroy et Restore : la suppression douce bien faite - [050] Filtrage et tri temporels - [051] Fonctions de comparaison temporelle (vous êtes ici) - [052] Accès aux métadonnées de version - [053] Arithmétique temporelle : ajouter des jours, comparer des dates - [054] Précision du suivi et validation - [055] Le modèle temporel complet : ce qu'aucun autre langage n'offre