SW-10-CSharp-RDFStar — Jumeau C# : annoter des triplets (réification) via dotNetRDF
Jumeau C# (.NET 9) de SW-10-Python-RDFStar. Le notebook Python utilise rdflib pour illustrer l’annotation de triplets (provenance, confiance, validité temporelle) via la réification classique RDF (4 triplets par assertion) et les graphes nommés (rdflib.Dataset). Ce jumeau invoque le vrai moteur .NET natif : dotNetRDF 3.4.1 (VDS.RDF.Graph, Triple, SparqlQueryParser, ExecuteQuery) — pas une réimplémentation jouet.
Comprendre la limitation fondamentale de RDF : un triplet est une affirmation atomique, on ne peut pas (en RDF 1.0) annoter directement un triplet.
Maîtriser la réification classique : décomposer une assertion en 4 triplets (rdf:Statement + rdf:subject/predicate/object) pour pouvoir l’annoter.
Construire un graphe de connaissances annoté : provenance, score de confiance, validité temporelle.
Interroger ces annotations via SPARQL (SparqlQueryParser + ExecuteQuery), avec filtrage.
Découvrir les graphes nommés comme alternative plus compacte à la réification.
Vérification mathématique (cibles de concordance)
Coût de la réification : annoter 1 fait = 7 triplets exactement (1 original + 4 réification + 2 annotations). Concordance avec rdflib.
Graphe de connaissances : 4 personnes (8 triplets type/name) + faits annotés.
Requête SPARQL filtrée : 2 relations à confiance ≥ 0,60 (Alice→Bob 0,95, Bob→Carol 0,60), 2 à confiance < 0,60 (Carol→Dave 0,30, Alice→Dave 0,50). Concordance avec le twin Python.
Posture du jumeau : mêmes concepts, moteur natif
La méthode de concordance fixée pour cette paire : chaque étape du notebook Python (reify() helper, graphe de connaissances, filtrage SPARQL, graphes nommés, sérialisation) possède ici son exécution dotNetRDF équivalente, avec les mêmes valeurs d’exemple (mêmes personnes, mêmes scores de confiance) — les compteurs de triplets et les tables de résultats doivent coïncider, aux différences d’API près. Quand un chiffre diverge entre les deux notebooks, l’écart est documenté dans le texte (il traduit presque toujours une différence de vocabulaire d’annotation, pas de mécanique). Cette discipline fait du couple un même cours exécutable dans deux écosystèmes, plutôt que deux cours parallèles.
1. Installation et espace de noms
dotNetRDF est le moteur .NET natif pour RDF/SPARQL. On charge le NuGet dotNetRDF 3.4.1 (le même que SW-9-CSharp-JSONLD). On définit les préfixes et URI de base comme le twin Python (ex:, foaf:, rdf:, xsd:). Côté espaces de noms, tout vit sous VDS.RDF.* : Graph/TripleStore pour les graphes, Parsing/Writing pour les sérialisations, Query pour SPARQL — miroir des modules rdflib.graph/rdflib.plugins.sparql du côté Python.
Le chargement #r "nuget: dotNetRDF, 3.4.1" installe le moteur depuis NuGet à la première exécution (mécanisme .NET Interactive), puis les using VDS.RDF.* ouvrent les quatre quadrants de la bibliothèque : VDS.RDF (le modèle : Graph, Triple, nœuds), VDS.RDF.Parsing (lecteurs Turtle/JSON-LD et SparqlQueryParser), VDS.RDF.Writing (sérialiseurs, dont CompressingTurtleWriter), VDS.RDF.Query (SparqlResultSet, ISparqlResult). La correspondance avec rdflib est terme à terme : Graph ↔︎ rdflib.Graph, Assert ↔︎ add, ExecuteQuery ↔︎ query — apprendre l’un, c’est savoir naviguer l’autre.
dotNetRDF charge (VDS.RDF) -- marathon #4956, parite avec SW-10-Python-RDFStar (rdflib)
2. Le problème : pourquoi annoter des triplets ?
RDF représente des faits sous forme de triplets (sujet, prédicat, objet). Mais un triplet est atomique : on ne peut pas lui attacher directement une provenance, un score de confiance ou une date. Pour dire « Alice affirme que Bob connaît Carol avec 85 % de confiance », il faut réifier le triplet — le décomposer en 4 triplets auxiliaires — puis annoter ce nœud-réification.
2.1 La réification classique : 4 triplets pour 1 assertion
La réification d’un triplet (s, p, o) crée un nœud stmt typé rdf:Statement relié au triplet original via rdf:subject, rdf:predicate, rdf:object. On peut alors annoter stmt (source, confiance, date) sans toucher au fait original.
Un point clé du vocabulaire : les quatre prédicats (rdf:type rdf:Statement, rdf:subject, rdf:predicate, rdf:object) sont des triplets ordinaires — le nœud stmt est un URI comme un autre, requêtable par SPARQL comme n’importe quelle ressource. La réification n’est pas une construction spéciale du moteur : c’est une convention de modélisation entièrement exprimée en RDF, ce qui explique à la fois sa portabilité totale (tout moteur RDF la comprend) et son coût (rien n’est gratuit puisque tout est explicite).
L’exemple filé du notebook
Tout le notebook s’appuie sur un même scénario : un graphe social où Alice, Bob, Carol et Dave sont reliés par foaf:knows, chaque relation étant rapportée par une source différente — LinkedIn (fiable), Twitter (moyenne), aucune source, ou une rumeur — avec un score de confiance en conséquence. La réification est ce qui permet au graphe de porter à la fois la relation (Alice connaît Bob) et son contexte épistémique (selon LinkedIn, à 95 %) au lieu de les confondre dans un triplet muet.
Une façon de sentir la limitation avant même le code : en RDF, un triplet (s, p, o) est insécable — il n’a pas d’identité propre, pas d’« intérieur » où ranger une métadonnée. Une propriété RDF s’attache à une ressource (le sujet), jamais à une affirmation. « La confiance de Bob knows Carol » n’est donc pas exprimable directement : il faut d’abord matérialiser l’affirmation en tant que ressource — c’est littéralement ce que fait le mot réification : transformer une chose (le triplet) en une chose d’un autre type (un nœud) pour pouvoir en parler. La cellule suivante fait cette matérialisation en sept triplets, et tout le reste du notebook en exploite les conséquences.
// Espace de noms et URI de base (identiques au twin Python)conststring NS ="http://example.org/";var g =newGraph();g.NamespaceMap.AddNamespace("ex",newUri(NS));g.NamespaceMap.AddNamespace("foaf",newUri("http://xmlns.com/foaf/0.1/"));// Helper : un URI node racourciIUriNode U(string local)=> g.CreateUriNode("ex:"+ local);IUriNode Pred(Uri u)=> g.CreateUriNode(u);IUriNode rdfType = g.CreateUriNode("rdf:type");IUriNode rdfStmt = g.CreateUriNode("rdf:Statement");IUriNode rdfSubject = g.CreateUriNode("rdf:subject");IUriNode rdfPredicate = g.CreateUriNode("rdf:predicate");IUriNode rdfObject = g.CreateUriNode("rdf:object");IUriNode foafKnows =Pred(newUri("http://xmlns.com/foaf/0.1/knows"));// Reification classique : decomposer le triplet (Bob, knows, Carol)g.Assert(newTriple(U("Bob"), foafKnows,U("Carol")));// le fait original (1)var stmt =U("statement1");g.Assert(newTriple(stmt, rdfType, rdfStmt));// rdf:type rdf:Statementg.Assert(newTriple(stmt, rdfSubject,U("Bob")));// rdf:subject Bobg.Assert(newTriple(stmt, rdfPredicate, foafKnows));// rdf:predicate foaf:knowsg.Assert(newTriple(stmt, rdfObject,U("Carol")));// rdf:object Carol (4)// Annotations : qui affirme ce fait, avec quel degre de confiance ?g.Assert(newTriple(stmt,U("assertedBy"),U("Alice")));// assertedBy Aliceg.Assert(newTriple(stmt,U("confidence"), g.CreateLiteralNode("0.85",newUri("http://www.w3.org/2001/XMLSchema#double"))));// (2)Console.WriteLine($"Nombre total de triplets : {g.Triples.Count}");Console.WriteLine(" - Fait original : 1 triplet");Console.WriteLine(" - Reification : 4 triplets");Console.WriteLine(" - Annotations : 2 triplets");Console.WriteLine(" - Total : 7 triplets pour annoter 1 fait");
Nombre total de triplets : 7
- Fait original : 1 triplet
- Reification : 4 triplets
- Annotations : 2 triplets
- Total : 7 triplets pour annoter 1 fait
Interprétation : coût de la réification
Approche
Triplets nécessaires
Lisibilité
Garantie d’existence du fait
Triplet nu
1
excellente
oui (le triplet existe)
Réification + annotations
7
verbeuse
non (le fait original peut ne pas exister)
C’est précisément ce coût (4 triplets auxiliaires + N annotations) que RDF-star (RDF 1.2) vise à éliminer avec la syntaxe << s p o >>. Mais la réification reste le mécanisme portable, indépendant du moteur. La sérialisation Turtle ci-dessous montre les 7 triplets. À l’échelle, l’arithmétique devient le vrai sujet : annoter 1 000 faits avec 2 annotations chacun coûte 7 000 triplets — et surtout, toute requête sur les faits doit désormais traverser la jointure rdf:Statement de la section 5. C’est ce coût en volume et en complexité de requête, plus que la verbosité, qui motive RDF-star : mêmes annotations, zéro triplet auxiliaire.
Lecture des mesures
La sortie affiche le compte exact : 7 triplets pour un fait annoté de deux manières — 1 fait, 4 auxiliaires, 2 annotations. Deux détails de la cellule méritent l’œil avant de passer au Turtle :
Le nœud-réification est ici un URI nommé (ex:statement1), construit par U("statement1"). Le twin Python laisse par défaut un blank node anonyme. Ce n’est pas un caprice d’API : un URI nommé reste adressable après sérialisation N-Triples (où les identifiants de blank nodes sont précaires) et référenceable depuis un autre graphe — au prix d’un nom à inventer. Le blank node économise le nommage mais borne la portée. Les deux notebooks démontrent la même mécanique avec ce choix différent, et la sérialisation ci-dessous le rend visible.
Le littéral de confiance est construit par g.CreateLiteralNode("0.85", new Uri(...#double)) : la forme lexicale "0.85" avec son datatype explicite. La forme doit utiliser le point décimal — d’où le helper Confidence() de la section 3 qui force InvariantCulture : un ToString() par défaut sur une machine en culture française produirait "0,85" et casserait silencieusement le littéral typé (plus personne ne saurait le comparer en SPARQL). Première rencontre, dans ce notebook, de la règle .NET « tout littéral qui traverse une frontière se formate en culture invariante ».
L’arithmétique à l’échelle annoncée ci-dessus se vérifie sur la ligne de compte : chaque annotation supplémentaire ne coûte que 1 triplet (c’est linéaire), mais les 4 auxiliaires sont dus une fois par fait annoté — c’est le facteur constant qui, multiplié par des millions de faits, remplit les stores et motive RDF-star.
Lecture du résultat : le Turtle compressé rend la réification lisible
La sérialisation ouvre sur les directives @prefix (rdf:, rdfs:, xsd:, ex:, foaf:) puis compacte chaque triplet réifié sur une ligne ex:stmt1 rdf:type rdf:Statement ; rdf:subject ... ; rdf:predicate ... ; rdf:object ... — le point-virgule Turtle (prédicats multiples pour un même sujet) suit exactement la structure du nœud-réification. C’est toute la valeur du format : là où le graphe en mémoire est une collection plate de triplets, le Turtle compressé regroupe visuellement les 4 triplets auxiliaires en un bloc par assertion, et les annotations du même nœud à sa suite. Le même contenu en N-Triples (une ligne par triplet, URIs en clair) resterait correct mais illisible — c’est le choix fait ici par CompressingTurtleWriter, l’équivalent exact du serialize(format="turtle") de rdflib côté Python.
Notez au passage deux choix du CompressingTurtleWriter visibles dans la sortie : il émet un @prefix rdfs:que le code n’a jamais déclaré (le writer pré-déclare les préfixes usuels du modèle, y compris inutilisés ici), et il réordonne les prédicats du nœud-réification par ordre alphabétique (ex:assertedBy, ex:confidence, rdf:object, rdf:predicate, rdf:subject, puis a rdf:Statement) sans respecter l’ordre d’insertion — un graphe RDF est un ensemble de triplets, l’ordre n’a pas de sens, et le sérialiseur en dispose librement pour compacter. Conséquence pratique pour les tests : ne jamais comparer deux sérialisations Turtle chaîne à chaîne ; comparer les graphes re-parsés (ou leurs comptes), comme le fait l’aller-retour de la section 8.
La ligne ex:statement1 ex:assertedBy ex:Alice; montre enfin la raison d’être du point-virgule Turtle : tout ce qui suit sur le bloc partage le même sujet — le nœud-réification et ses six propriétés forment visuellement une fiche, alors qu’en mémoire ce sont sept triplets indépendants.
3. Fonction utilitaire Reify()
Pour réduire la verbosité, le twin Python définit une fonction reify(graph, s, p, o). Voici l’équivalent C# : elle crée le nœud-réification rdf:Statement + les 3 liens, sans répéter le fait original (à ajouter séparément). Retourne le nœud stmt pour annotation. Plutôt que d’écrire les 4 triplets auxiliaires à la main à chaque annotation, on factorise le geste dans un helper — c’est aussi l’occasion d’expliciter son contrat : ce qu’il ajoute au graphe, ce qu’il laisse à l’appelant, et pourquoi.
Deux différences de signature avec le twin Python valent d’être repérées avant d’exécuter. D’abord, le paramètre name : là où reify() Python accepte un stmt_uri=None (blank node par défaut), la version C# exige un nom — elle retourne un IUriNode construit par U(name). Ensuite, elle ne prend pas le graphe en paramètre : elle asserte directement dans le g capturé par la méthode locale (fermeture sur la variable de cellule). Deux ergonomies, un même contrat : quatre triplets auxiliaires ajoutés, nœud retourné à l’appelant pour annotation, fait original non touché.
Le helper Confidence(double v) juste en dessous porte la subtilité de culture vue en section 2 : v.ToString(CultureInfo.InvariantCulture) garantit la forme lexicale à point ("0.6"), seul format conforme pour xsd:double.
// Reify : cree les 4 triplets de reification et retourne le noeud statement.// (Le fait original doit etre ajoute separement si on veut qu'il existe dans le graphe.)IUriNode Reify(IUriNode s, IUriNode p, IUriNode o,string name){var stmtNode =U(name); g.Assert(newTriple(stmtNode, rdfType, rdfStmt)); g.Assert(newTriple(stmtNode, rdfSubject, s)); g.Assert(newTriple(stmtNode, rdfPredicate, p)); g.Assert(newTriple(stmtNode, rdfObject, o));return stmtNode;}// Helper pour creer un LiteralNode de confiance typé xsd:double.ILiteralNode Confidence(double v)=> g.CreateLiteralNode(v.ToString(System.Globalization.CultureInfo.InvariantCulture),newUri("http://www.w3.org/2001/XMLSchema#double"));// Demo : un 2e fait reifie via le helper (Bob connait Dave, affirmé par Carol).g.Assert(newTriple(U("Bob"), foafKnows,U("Dave")));// fait originalvar stmt2 =Reify(U("Bob"), foafKnows,U("Dave"),"statement2");g.Assert(newTriple(stmt2,U("assertedBy"),U("Carol")));g.Assert(newTriple(stmt2,U("confidence"),Confidence(0.60)));Console.WriteLine($"Apres 2e fait reifie : graphe g = {g.Triples.Count} triplets au total");
Apres 2e fait reifie : graphe g = 14 triplets au total
Lecture du résultat : le contrat de Reify()
Après le second appel, le graphe g compte 14 triplets : les 7 du premier fait réifié (1 original + 4 de réification + 2 annotations) plus 7 pour le second. La fonction illustre un contrat important : elle retourne le nœud-réification (IUriNode) sans l’ajouter au graphe — c’est l’appelant qui décide d’Assert le fait original ou non. Cette séparation reflète une subtilité du modèle : la réification décrit une assertion, pas un fait — le triplet Alice connaît Carol peut être réifié (quelqu’un l’affirme) sans exister dans le graphe (le système ne l’endosse pas). C’est aussi ce qui rend le mécanisme général : n’importe quelle cellule peut annoter n’importe quel triplet, y compris un triplet qu’elle refuse d’affirmer elle-même.
La mesure confirme le contrat : le graphe passe de 7 à 14 triplets — le second fait réifié (Bob connaît Dave, affirmé par Carol à 0,60) ajoute exactement le même bloc 1 + 4 + 2. La linéarité est la bonne nouvelle (chaque annotation ne coûte qu’un triplet de plus) ; la constante de 4 auxiliaires par fait est la mauvaise — elle ne se négocie pas tant que le mécanisme est la réification.
Sur la distinction assertion/fait, un prolongement concret : imaginez un module de vérification qui reçoit des affirmations externes sans vouloir les endosser. Avec Reify(), il enregistre chaque affirmation (le bloc auxiliaire + annotations) et s’abstient du Assert du triplet original — le graphe des faits (ce que le système affirme) reste vierge de toute contamination, tandis que le graphe des mentions (ce que d’autres affirment) grossit. La requête de la section 5 liste les deux sans les distinguer — c’est au consommateur de choisir son motif : jointure sur les triplets nus pour raisonner, jointure sur rdf:Statement pour auditer.
4. Graphe de connaissances annoté
Construisons un graphe réaliste : 4 personnes (Alice, Bob, Carol, Dave) reliées par foaf:knows, chaque relation annotée d’un score de confiance et d’une source. Le fait 4 (Alice connaît Dave) est seulement affirmé par Bob mais n’existe pas comme triplet direct — c’est précisément l’intérêt de la réification : on peut enregistrer une affirmation sans l’ajouter au graphe des faits.
La construction de la cellule suit le plan du twin Python à l’identique — quatre personnes typées foaf:Person avec leurs noms complets, puis quatre relations dont trois assertées et une seulement mentionnée. Les scores sont calibrés pour que le filtrage de la section 6 produise une partition équilibrée (2 fiables / 2 incertaines) : 0,95 (LinkedIn), 0,60 (Twitter), 0,30 (Rumeur), et 0,50 pour la relation non sourcée affirmée par Bob.
Observons aussi le geste d’API : le graphe gk repart de zéro (new Graph()) avec ses propres helpers locaux (N, Conf, ReifyK) — en .NET Interactive, chaque cellule compile dans un espace partagé, et re-déclarer Reify provoquerait une collision de symboles ; le suffixe K (pour knowledge graph) neutralise le conflit. C’est une contrainte du notebook C#, pas du moteur — côté Python, chaque cellule redéfinit reify sans état résiduel.
Côté vocabulaire, le triplet d’annotations retenu (ex:confidence, ex:source, ex:assertedBy) n’est pas du RDF standard mais un choix de modélisation local — le standard fournit le mécanisme (le nœud rdf:Statement annotables), pas les prédicats d’annotation. Une industrie qui a besoin d’interopérer choisirait des prédicats publiés (le W3C PROV pour la provenance, par exemple) ; ici la brièveté l’emporte. La contrepartie à connaître : deux graphes annotés avec des vocabulaires différents ne se requêtent pas par la même clause — choisir ses prédicats d’annotation est un engagement d’API autant qu’un choix sémantique.
var gk =newGraph();gk.NamespaceMap.AddNamespace("ex",newUri(NS));gk.NamespaceMap.AddNamespace("foaf",newUri("http://xmlns.com/foaf/0.1/"));// Helpers locaux (gk)IUriNode N(string local)=> gk.CreateUriNode("ex:"+ local);IUriNode foafName = gk.CreateUriNode(UriFactory.Create("http://xmlns.com/foaf/0.1/name"));IUriNode rdfTypeK = gk.CreateUriNode("rdf:type");IUriNode foafPerson = gk.CreateUriNode(UriFactory.Create("http://xmlns.com/foaf/0.1/Person"));IUriNode knowsK = gk.CreateUriNode(UriFactory.Create("http://xmlns.com/foaf/0.1/knows"));ILiteralNode Str(string s)=> gk.CreateLiteralNode(s);ILiteralNode Conf(double v)=> gk.CreateLiteralNode( v.ToString(System.Globalization.CultureInfo.InvariantCulture),newUri("http://www.w3.org/2001/XMLSchema#double"));// Reify sur gk : retourne le noeud statementIUriNode ReifyK(IUriNode s, IUriNode p, IUriNode o,string name){var st =N(name); gk.Assert(newTriple(st, rdfTypeK, gk.CreateUriNode("rdf:Statement"))); gk.Assert(newTriple(st, gk.CreateUriNode("rdf:subject"), s)); gk.Assert(newTriple(st, gk.CreateUriNode("rdf:predicate"), p)); gk.Assert(newTriple(st, gk.CreateUriNode("rdf:object"), o));return st;}// === Personnes ===foreach(var(name, uri)innew[]{("Alice Dupont",N("Alice")),("Bob Martin",N("Bob")),("Carol Laurent",N("Carol")),("Dave Petit",N("Dave"))}){ gk.Assert(newTriple(uri, rdfTypeK, foafPerson)); gk.Assert(newTriple(uri, foafName,Str(name)));}// === Fait 1 : Alice connait Bob (haute confiance, LinkedIn) ===gk.Assert(newTriple(N("Alice"), knowsK,N("Bob")));var s1 =ReifyK(N("Alice"), knowsK,N("Bob"),"stmt1");gk.Assert(newTriple(s1,N("confidence"),Conf(0.95)));gk.Assert(newTriple(s1,N("source"),Str("LinkedIn")));// === Fait 2 : Bob connait Carol (confiance moyenne, Twitter) ===gk.Assert(newTriple(N("Bob"), knowsK,N("Carol")));var s2 =ReifyK(N("Bob"), knowsK,N("Carol"),"stmt2");gk.Assert(newTriple(s2,N("confidence"),Conf(0.60)));gk.Assert(newTriple(s2,N("source"),Str("Twitter")));// === Fait 3 : Carol connait Dave (basse confiance, rumeur) ===gk.Assert(newTriple(N("Carol"), knowsK,N("Dave")));var s3 =ReifyK(N("Carol"), knowsK,N("Dave"),"stmt3");gk.Assert(newTriple(s3,N("confidence"),Conf(0.30)));gk.Assert(newTriple(s3,N("source"),Str("Rumeur")));// === Fait 4 : Alice connait Dave (NON asserte, seulement affirme par Bob) ===var s4 =ReifyK(N("Alice"), knowsK,N("Dave"),"stmt4");gk.Assert(newTriple(s4,N("assertedBy"),N("Bob")));gk.Assert(newTriple(s4,N("confidence"),Conf(0.50)));Console.WriteLine($"Graphe de connaissances : {gk.Triples.Count} triplets");
Graphe de connaissances : 35 triplets
Lecture du résultat : le graphe de connaissances assemblé
35 triplets composent maintenant le graphe gk : les faits foaf:knows entre les quatre personnes, leurs noms, et pour chaque relation un bloc de réification portant ex:confidence (le score) et ex:source (la provenance). C’est l’échelle où l’inspection à l’œil devient pénible — 35 triplets, 4 sources hétérogènes (LinkedIn, Twitter, aucune, Rumeur), des confiances de 0,30 à 0,95 — et où la question naturelle cesse d’être « qu’y a-t-il dans le graphe ? » pour devenir « que sait-on, et à quel degré croire chaque chose ? ». Les deux sections suivantes répondent à cette question dans la langue du moteur : SPARQL.
Décomposition des 35 triplets
Le compte se vérifie bloc par bloc dans le code : 8 triplets d’identité (4 personnes × rdf:type + foaf:name), fait 1 : 7 (1 assertion + 4 auxiliaires + 2 annotations), fait 2 : 7, fait 3 : 7, fait 4 : 6 (aucune assertion — 4 auxiliaires + 2 annotations). Total : 8 + 7 + 7 + 7 + 6 = 35, à l’identique du compteur affiché. Le twin Python mesure 37 sur le même scénario : ses deux premiers faits portent une annotation ex:since supplémentaire chacun — l’écart de 2 triplets traduit un vocabulaire d’annotation plus riche côté Python, pas une mécanique différente. C’est le type de divergence documentée qu’une paire jumelle doit rendre lisible plutôt que masquer.
La conséquence la plus importante reste structurelle : cherchez le triplet ex:Alice foaf:knows ex:Dave dans ce graphe — il n’existe pas. Seul le bloc stmt4 en parle. Un parcours des foaf:knows ne voit que 3 relations ; la quatrième n’existe que pour la jointure sur rdf:Statement. Assertion et mention ont des audiences distinctes dans le même store — le moteur d’inférence raisonne sur les assertions, l’auditeur sur les mentions, et personne ne confond les deux.
5. Interroger les annotations avec SPARQL
Pour extraire les relations annotées, on joint sur rdf:subject/rdf:predicate/rdf:object et on récupère les annotations. La requête ci-dessous liste toutes les relations connues avec leur confiance et source, triées par confiance décroissante.
Ce que la requête fait ne peut pas se faire sans réification : un triplet nu n’a pas de « confiance » ni de « source » à joindre — la jointure ci-dessous existe précisément parce que les annotations vivent sur le nœud rdf:Statement.
L’exécution passe par les trois gestes canoniques dotNetRDF : new SparqlQueryParser() puis ParseFromString(texte) produit un objet requête compilé, gk.ExecuteQuery(q) l’évalue contre le graphe et retourne un SparqlResultSet que l’on parcourt en foreach. Les deux helpers d’extraction (ConfVal, LitStr) existent pour un trait d’API à connaître : r[v].ToString() sur un littéral typé renvoie la forme avec suffixe (0.95^^...#double) — pour comparer ou afficher, il faut descendre à ((ILiteralNode)r[v]).Value (la forme lexicale nue) et parser soi-même. Le twin Python n’a pas cette marche : rdflib retourne directement des float pour les littéraux typés numériques. Économie d’un cast, coût d’un helper — le prix habituel de l’API typée .NET.
Une remarque d’ordre de grandeur pour situer la jointure : sur ce graphe de 35 triplets, la requête traverse 4 nœuds rdf:Statement et 8 patterns par ligne de résultat — invisible à cette échelle. Sur le graphe industriel de la section 2 (mille faits annotés, sept mille triplets), la même requête joint chaque annotation à son fait via les trois patterns rdf:subject/rdf:predicate/rdf:object : c’est le moment où le choix du moteur compte (dotNetRDF tient la charge sur des graphes de travail ; un store dédié comme Jena/Fuseki prend le relais au-delà), et où RDF-star, qui supprime la jointure en la remplaçant par un motif direct, change réellement la classe de coût.
// Requete 1 : toutes les relations annotées (joiture sur rdf:Statement)string queryAll = @"PREFIX rdf:<http://www.w3.org/1999/02/22-rdf-syntax-ns#>PREFIX ex:<http://example.org/>PREFIX foaf:<http://xmlns.com/foaf/0.1/>SELECT ?person1 ?person2 ?confidence ?sourceWHERE {?stmt a rdf:Statement ; rdf:subject ?person1 ; rdf:predicate foaf:knows ; rdf:object?person2 ; ex:confidence ?confidence . OPTIONAL {?stmt ex:source ?source .}}ORDER BY DESC(?confidence)";// Helper : extraire la valeur lexicale d'un LiteralNode (sans le suffixe ^^datatype).// r[v].ToString() renvoie "0.95^^...XSD#double" -> on veut "0.95".doubleConfVal(ISparqlResult r,string v)=>double.Parse(((ILiteralNode)r[v]).Value, System.Globalization.CultureInfo.InvariantCulture);// Helper : valeur lexicale d'un LiteralNode string (sans le suffixe ^^datatype).stringLitStr(ISparqlResult r,string v)=> r.HasValue(v)&& r[v]is ILiteralNode ln ? ln.Value:"-";var parser =newSparqlQueryParser();var q = parser.ParseFromString(queryAll);var results = gk.ExecuteQuery(q)as SparqlResultSet;Console.WriteLine("=== Relations annotées (triées par confiance) ===");Console.WriteLine($"{"Personne 1",-12} {"Personne 2",-12} {"Confiance",10} {"Source",-12}");Console.WriteLine(newstring('-',50));foreach(var r in results){var p1 = r["person1"].ToString().Split('/').Last();var p2 = r["person2"].ToString().Split('/').Last();var conf =ConfVal(r,"confidence");var src =LitStr(r,"source"); Console.WriteLine($"{p1,-12} {p2,-12} {conf,10:F2} {src,-12}");}
=== Relations annotées (triées par confiance) ===
Personne 1 Personne 2 Confiance Source
--------------------------------------------------
Alice Bob 0,95 LinkedIn
Bob Carol 0,60 Twitter
Alice Dave 0,50 -
Carol Dave 0,30 Rumeur
Lecture du résultat : la jointure rdf:Statement restitue les annotations
La requête produit la table des quatre relations annotées, triées par confiance décroissante : Alice→Bob à 0,95 (LinkedIn), Bob→Carol à 0,60 (Twitter), Alice→Dave à 0,50 (source « - »), Carol→Dave à 0,30 (Rumeur). Mécaniquement, chaque ligne est le produit d’une jointure : les quatre triplets rdf:type rdf:Statement identifient les nœuds-réifications, les patterns rdf:subject/rdf:predicate/rdf:object récupèrent la relation décrite, et les patterns ex:confidence/ex:source ramènent les annotations du même nœud — tout l’appareil de la section 2, requêté en une passe. Un détail mérite l’œil : la ligne Alice→Dave affiche « - » comme source. Ce n’est pas une donnée manquée par la requête — la relation a été réifiée sans annotation ex:source, et SPARQL, dont la sémantique est ouverte, ne fabrique rien : le OPTIONAL (ou l’absence de pattern obligatoire) laisse la variable non liée, rendue ici par le tiret. Une absence d’information reste une information explicite.
Un détail d’affichage français à ne pas confondre avec une donnée : la table montre « 0,95 » avec virgule décimale. La forme lexicale stockée dans le graphe est bien "0.95" à point (conforme xsd:double, grâce à InvariantCulture dans le helper) ; c’est le formatage console{conf,10:F2} qui applique la culture courante de la machine — sur un système configuré en français, le double s’affiche avec virgule. Deux couches bien distinctes : la forme lexicale RDF (invariante, échangée) et le rendu terminal (local, éphémère). Les twins doivent cette vigilance à tout littéral numérique qui traverse une frontière — stockage invariant, affichage local, et jamais l’inverse.
Sur le fond, la table confirme la concordance fixée en tête de notebook : mêmes quatre relations, mêmes scores, même ordre que le twin Python (0,95 → 0,60 → 0,50 → 0,30) — la jointure rdf:Statement restitue fidèlement l’appareil de la section 2, y compris l’absence non liée du OPTIONAL rendue par le tiret.
6. Filtrer par score de confiance
Cas d’usage clé : séparer les faits fiables (confiance ≥ 0,60) des faits incertains (< 0,60). On utilise une clause FILTER SPARQL.
Le seuil de 0,60 n’est pas une constante du standard : c’est un choix de politique de consommation, arbitré selon le coût d’une erreur dans chaque sens. Une application de recommandation tolérera 0,40 ; un dossier d’enquête exigera 0,90. SPARQL rend ce choix externe au graphe — les données portent des scores continus, la politique vit dans la requête, et changer de seuil ne touche aucune donnée.
Syntaxe C# à noter pour la requête : le littéral du FILTER est écrit ""0.60""^^xsd:double — le doublement des guillemets est l’échappement des verbatim strings C# (@"..."), pas une convention SPARQL. Côté requête elle-même, tout est standard : le FILTER compare le littéral typé du graphe au littéral typé de la requête, et l’ordre de sortie (DESC pour les fiables, ascendant pour les incertaines) met chaque moitié dans sa lecture naturelle — la plus solide en tête d’une part, la plus douteuse en tête d’autre part.
=== Relations FIABLES (confiance >= 0.60) : 2 ===
Alice connait Bob (confiance=0,95, source=LinkedIn)
Bob connait Carol (confiance=0,60, source=Twitter)
=== Relations INCERTAINES (confiance < 0.60) : 2 ===
Carol connait Dave (confiance=0,30, source=Rumeur)
Alice connait Dave (confiance=0,50, source=-)
Lecture du résultat : le seuil partitionne exactement
Le FILTER (?confidence >= 0.60) coupe le graphe en deux moitiés : 2 relations fiables (Alice→Bob 0,95 LinkedIn ; Bob→Carol 0,60 Twitter) et 2 incertaines (Alice→Dave 0,50 sans source ; Carol→Dave 0,30 Rumeur). La partition n’a rien de magique — 0,60 est un seuil métier arbitré ici pour l’exemple — mais sa lecture est instructive : elle recoupe presque exactement la frontière des sources. Ce qui passe le seuil vient de plateformes nominatives (LinkedIn, Twitter) ; ce qui échoue est soit sans source, soit une « Rumeur ». La confiance chiffrée et la provenance qualitative racontent la même histoire par deux canaux — c’est précisément pourquoi on a annoté les deux : un consommateur du graphe peut filtrer sur le score, auditer via la source, ou exiger leur accord.
La sortie chiffrée le partitionnement annoncé : 2 fiables (Alice→Bob 0,95 LinkedIn ; Bob→Carol 0,60 Twitter), 2 incertaines (Alice→Dave 0,50 sans source ; Carol→Dave 0,30 Rumeur). Le recoupement avec les sources est la vraie leçon de cette table : la frontière du seuil coïncide avec la frontière qualitative des provenances. Ce n’est pas un hasard de l’exemple — c’est la situation visée : quand la confiance est bien calibrée, filtrer sur le score ou auditer par la source conduit à la même partition, et l’écart entre les deux canaux signale les cas à examiner (une source nominative à faible score, ou une rumeur bien notée). Dans un pipeline réel, ce sont précisément ces écarts qu’on met en file d’attente de vérification humaine.
7. Alternative : les graphes nommés
Les graphes nommés (Named Graphs) offrent une autre approche pour grouper des triplets par contexte : plutôt que de réifier chaque triplet individuellement, on regroupe un ensemble de triplets sous une URI de graphe. On peut alors annoter le graphe tout entier (provenance, confiance globale). dotNetRDF expose cela via la classe VDS.RDF.ThreadSafeTripleStore + IGraph nommés.
La granularité est le trade-off central : réification = une annotation par triplet (fin, coûteux), graphe nommé = une annotation par source (grossier, économique). En pratique les deux cohabitent — un graphe nommé par source, contenant des triplets réifiés quand le score varie à l’intérieur d’une même source.
L’alternative change l’unité d’annotation : plus « un nœud par triplet à annoter » mais « un graphe par source qui parle ». Dans la cellule suivante, chaque plateforme (LinkedIn, Twitter) devient un graphe nommé contenant ses affirmations — l’URI du graphe porte la provenance, et une annotation posée sur cette URI (confiance de la source, date de collecte) vaut pour tout son contenu. Le store devient la structure porteuse (TripleStore), et l’identité d’un graphe passe par son nœud-nom — un point de migration dotNetRDF 3.x que la cellule documente en commentaire et que la lecture qui suit détaille.
var store =newTripleStore();// dotNetRDF 3.x : un graphe nomme se construit via Graph(IRefNode) -- le noeud-nom// (Name, IRefNode) definit l'identite du graphe dans le store. Le vieux pattern// `new Graph { BaseUri = uri }` laisse Name == null et les graphes se confondent// avec le graphe par defaut -> collision a l'ajout. On garde aussi un guard HasGraph// (non deprecate, via IRefNode) pour rester idempotent sur re-execution dans l'IDE.var nLinkedIn =newGraph().CreateUriNode(UriFactory.Create(NS +"graph/linkedin"));var gLinkedIn =newGraph(nLinkedIn);gLinkedIn.Assert(newTriple( gLinkedIn.CreateUriNode(UriFactory.Create(NS +"Alice")), gLinkedIn.CreateUriNode(UriFactory.Create("http://xmlns.com/foaf/0.1/knows")), gLinkedIn.CreateUriNode(UriFactory.Create(NS +"Bob"))));if(!store.HasGraph(nLinkedIn)) store.Add(gLinkedIn);var nTwitter =newGraph().CreateUriNode(UriFactory.Create(NS +"graph/twitter"));var gTwitter =newGraph(nTwitter);gTwitter.Assert(newTriple( gTwitter.CreateUriNode(UriFactory.Create(NS +"Bob")), gTwitter.CreateUriNode(UriFactory.Create("http://xmlns.com/foaf/0.1/knows")), gTwitter.CreateUriNode(UriFactory.Create(NS +"Carol"))));if(!store.HasGraph(nTwitter)) store.Add(gTwitter);// dotNetRDF 3.x : les requetes sur store passent par un ISparqlQueryProcessor.// On liste ici directement les graphes nommes du store et leur contenu (plus pedagogique).Console.WriteLine("=== Graphes nommés du store ===");foreach(IGraph ng in store.Graphs){var gName = ng.Nameis IUriNode un ? un.Uri.ToString().Split('/').Last():"(default)"; Console.WriteLine($" graphe {gName} : {ng.Triples.Count} triplet(s)");foreach(var t in ng.Triples) Console.WriteLine($" {t.Subject} {t.Predicate} {t.Object}");}
Lecture du résultat : deux niveaux de granularité pour annoter
Le store liste ses graphes nommés — linkedin et twitter, 1 triplet chacun — et leur contenu : la relation que chaque source affirme. Là où la réification annotait chaque triplet individuellement (4 triplets auxiliaires par assertion), le graphe nommé annote un ensemble entier d’un seul geste : l’URI du graphe (ex:linkedin) joue le rôle de provenance, et toute annotation posée sur cette URI (confiance globale, date de collecte, licence) vaut pour tous ses triplets. Note d’API visible dans le code : en dotNetRDF 3.x, un graphe nommé se construit par new Graph(IRefNode) — le nœud-nom définit l’identité — là où l’ancien pattern BaseUri laissait Name == null et un graphe implicitement « par défaut » ; c’est le piège de migration documenté dans le commentaire de la cellule. Le choix réification vs graphes nommés n’est donc pas esthétique : c’est un arbitrage entre granularité fine (chaque triplet porte son propre score) et granularité de source (un score par provenance) — le graphe de connaissances de la section 4 utilise la première, ce store la seconde.
Lecture des mesures et de l’API
Le store liste 2 graphes nommés, 1 triplet chacun : linkedin porte Alice knows Bob, twitter porte Bob knows Carol. Comparez la facture : ces deux relations, réifiées comme en section 4, coûteraient 2 × 4 auxiliaires ; ici elles ne coûtent rien de plus que leur existence — c’est l’économie du mécanisme, et sa contrepartie (les deux relations du même graphe ne peuvent plus porter des confiances différentes sans re-scinder le graphe).
Sur l’API, le commentaire de la cellule mérite son poids : en dotNetRDF 3.x, new Graph(nLinkedIn) (constructeur par nœud) est la seule façon de donner au graphe une identité correcte dans le store. L’ancien pattern new Graph { BaseUri = uri } laisse Name == null — à l’ajout au store, le graphe se confond avec le graphe par défaut, et le second ajout écrase ou fusionne le premier : un bug silencieux typique des migrations 2.x → 3.x. Le guard if (!store.HasGraph(n)) complète la robustesse : la cellule devient idempotent en re-exécution dans l’IDE (un Add répété d’un même nom lèverait autrement une exception de graphe déjà présent).
8. Sérialisation et interopérabilité
dotNetRDF supporte plusieurs formats de sérialisation (Turtle, N-Triples, RDF/XML, JSON-LD). Le Turtle (compressé) est le plus lisible pour la réification.
Le test d’interopérabilité le plus simple est l’aller-retour : sérialiser côté C# avec dotNetRDF, relire côté Python avec rdflib.graph().parse(format="turtle") — le graphe relit doit être isomorphe au graphe sérialisé. C’est le contrat de la paire SW-10 : le même contenu, prouvé depuis les deux écosystèmes.
La cellule suivante sérialise le graphe de connaissances complet (35 triplets, réifications comprises) en Turtle compressé et affiche les compteurs de volume. La question que ce test pose n’est pas « est-ce joli ? » mais « est-ce échangeable ? » : le texte produit doit être consommable tel quel par rdflib côté Python, par Jena, par tout parseur Turtle conforme — la sérialisation n’est qu’un encodage du graphe abstrait, et la preuve d’interopérabilité se joue à la relecture.
// Serialisation du graphe de connaissances en Turtle compressévar w =newCompressingTurtleWriter();var sw2 =new System.IO.StringWriter();w.Save(gk, sw2);var turtle = sw2.ToString();Console.WriteLine($"Serialisation Turtle : {turtle.Length} caracteres, {gk.Triples.Count} triplets");Console.WriteLine("--- (extrait) ---");Console.WriteLine(turtle.Substring(0, Math.Min(500, turtle.Length)));
Lecture du résultat : 1370 caractères pour 35 triplets
La sérialisation du graphe complet tient en 1370 caractères pour 35 triplets — environ 39 caractères par triplet, contre plus de 100 en N-Triples où chaque URI est répétée en clair. Le gain vient de la compression : préfixes déclarés une fois, prédicats multiples factorisés par le point-virgule, objets partagés par la virgule. C’est aussi ce qui fait du Turtle le format d’interopérabilité pratique : le fichier produit ici est parsable tel quel par rdflib (le twin Python), par Jena, ou par tout moteur conforme — la sérialisation est un encodage du même graphe abstrait, pas une donnée propriétaire. L’aller-retour sérialiser/relire sans perte est l’invariant qui rend la chaîne C# → disque → Python possible.
Lecture des mesures
Les compteurs donnent la densité exacte : 1370 caractères pour 35 triplets, soit ~39 caractères par triplet compressé — contre plus de 100 en N-Triples non compressé (URIs en clair, une ligne par triplet). Le facteur ~2,5 tient à trois compressions visibles dans l’extrait : les préfixes déclarés une fois pour toutes (ex: pour 20 caractères d’URI économisés par occurrence), le point-virgule qui factorise le sujet commun, et le regroupement des objets d’un même prédicat.
L’extrait s’interrompt au milieu du nom de Carol — il ne montre que les 500 premiers caractères (coupure Substring(0, 500) du code), pas une sérialisation tronquée : les 870 caractères restants contiennent les noms manquants et les quatre blocs rdf:Statement. Détail de lecture : la sérialisation n’affiche aucunex:Alice foaf:knows ex:Dave — la relation mentionnée de la section 4 est invisible hors de son bloc de réification, comme prévu.
Exercices
Les exercices ci-dessous sont à compléter (convention C.1 : stubs exécutables sans erreur).
Exercice 1 — Ajouter une annotation de date de validité
Étendez le graphe de connaissances : pour chaque fait annoté, ajoutez un triplet ex:validSince avec une date typée xsd:date. Modifiez ensuite la requête SPARQL pour afficher cette colonne.
Méthode conseillée : les trois exercices réutilisent les objets déjà construits (gk, stmt1..stmt4, store) — aucune reconstruction n’est nécessaire, seulement des Assert additionnels et des requêtes. En cas de blocage, le réflexe est toujours le même : retourner à la section dont l’exercice transpose le geste (sections 4, 5 et 7 respectivement), relire la requête modèle, puis n’en changer que la clause demandée.
// Exercice 1 : annotation temporelle// TODO : pour chaque stmt (stmt1..stmt4), ajouter gk.Assert(new Triple(stmt, N("validSince"),// gk.CreateLiteralNode("2020-01-01", xsd:date))).// Indice : utilisez gk.CreateLiteralNode(dateStr, new Uri("http://www.w3.org/2001/XMLSchema#date")).// Etape 1 : choisir une date de validité par fait. Etape 2 : modifier queryAll pour ajouter ?since.Console.WriteLine("Exercice 1 a completer : annotation de date de validite sur les faits.");
Exercice 1 a completer : annotation de date de validite sur les faits.
Exercice 2 — Requête de transitivité inférée
Alice connaît Bob (0,95), Bob connaît Carol (0,60). Écrivez une requête SPARQL qui calcule les « connaissances transitives » (chemins de longueur 2) et combine les confiances par produit.
Piège classique de l’exercice : la jointure double doit porter sur deux nœuds-réifications distincts (?stmtA pour a→b, ?stmtB pour b→c), sinon la requête ne matche rien. Et le produit des confiances se fait dans le SELECT ((?cA * ?cB) AS ?confChemin), pas dans le WHERE — SPARQL n’évalue d’expressions arithmétiques que sur les résultats. Vérification attendue : Alice→Bob→Carol donne 0,95 × 0,60 = 0,57.
// Exercice 2 : transitivité des connaissances// TODO : requete SPARQL avec deux jointures ?a foaf:knows ?b . ?b foaf:knows ?c .// Indice : la confiance d'un chemin a->b->c = confiance(a->b) * confiance(b->c).// Etape 1 : ecrire la jointure sur les stmt reifies. Etape 2 : multiplier les confiances dans le SELECT.Console.WriteLine("Exercice 2 a completer : requete de connaissances transitives (longueur 2).");
Exercice 2 a completer : requete de connaissances transitives (longueur 2).
Exercice 3 — Graphes nommés multi-sources
Construisez un 3e graphe nommé (par ex. graph/intranet) avec un fait Bob→Dave, puis écrivez une requête qui fusionne les 3 graphes et identifie les faits présents dans plusieurs sources.
L’indice de la cellule contient les deux clés : GRAPH ?g { ?s foaf:knows ?o } ouvre tous les graphes nommés du store en une seule requête, et le GROUP BY ?s ?o HAVING(COUNT(*) > 1) isole les faits corroborés par plusieurs sources. Avec les données présentes (linkedin : Alice→Bob ; twitter : Bob→Carol), il faudra que votre graphe intranet affirme l’un des deux faits existants pour que la corroboration produise une ligne — sinon la requête retourne vide, ce qui sera aussi une réponse à interpréter.
// Exercice 3 : fusion multi-sources via graphes nommés// TODO : ajouter store.Add(gIntranet) avec un fait, puis requete UNION/GRAPH combinant les sources.// Indice : une requete SELECT ?g ?s ?o WHERE { GRAPH ?g { ?s foaf:knows ?o } } liste tous les faits// avec leur graphe d'origine ; un GROUP BY ?s ?o HAVING(COUNT(*)>1) trouve les faits multi-sources.Console.WriteLine("Exercice 3 a completer : 3e graphe nommé + detection des faits multi-sources.");
Exercice 3 a completer : 3e graphe nommé + detection des faits multi-sources.
Conclusion — synthèse
Mécanisme
Coût (triplets)
Lisibilité
Portabilité
Triplet nu
1
excellente
totale
Réification classique
4 + N annotations
verbeuse
totale (RDF 1.0)
RDF-star (<< >>)
1 + N
excellente
récente (RDF 1.2)
Graphes nommés
1 + annotation du graphe
bonne
totale (SPARQL 1.1)
Leçon clé : la réification classique est le mécanisme portable pour annoter des triplets (provenance, confiance, temporalité) — au prix d’une verbosité de 4 triplets auxiliaires par fait. RDF-star (RDF 1.2) vise à éliminer ce coût avec une syntaxe dédiée << s p o >>, mais reste un standard jeune. Les graphes nommés offrent un compromis intermédiaire : grouper des triplets par contexte et annoter le groupe. dotNetRDF gère les trois (Graph, SparqlQueryParser, TripleStore) avec la même API VDS.RDF que ce notebook utilise.
Triplet nu quand la donnée est certaine et sans contexte à porter — l’essentiel d’un graphe de faits.
Réification quand chaque assertion porte son propre degré de croyance ou sa propre provenance — fusion de sources hétérogènes, graphe de connaissances auditable.
Graphe nommé quand la provenance est homogène par lots — ingestion multi-sources où chaque source parle pour tout ce qu’elle apporte.
RDF-star quand le moteur le supporte : la même expressivité que la réification à la syntaxe près, au prix d’une adoption encore partielle (RDF 1.2).
La compétence visée n’est pas de connaître les quatre par cœur, mais de choisir la granularité d’annotation adaptée à la question « qui affirme quoi, et à quel point le croire ? ».
Ce qu’il faut retenir côté C
Les gestes du notebook se traduisent un-à-un vers rdflib (Graph ↔︎ rdflib.Graph, Assert ↔︎ add, CompressingTurtleWriter ↔︎ serialize(format="turtle"), TripleStore + graphe nommé ↔︎ ConjunctiveGraph/Dataset) : la compétence transférable est le modèle RDF — réification, nœuds, sérialisation, SPARQL — pas l’API. La différence notable est culturelle : dotNetRDF expose l’identité d’un graphe nommé par son nœud (Graph(IRefNode)), quand rdflib la porte par URI — même mathématique, ergonomie propre à chaque écosystème.
Fil des mesures du jumeau
Le parcours chiffré du notebook condense le tableau ci-dessus : 7 triplets pour le premier fait annoté (section 2), 14 après le second (section 3), 35 pour le graphe de connaissances complet (section 4) — la réification facture partout ses 4 auxiliaires par fait, et la sérialisation finale (1370 caractères, section 8) en montre le volume. Les graphes nommés (section 7) n’ont facturé que 2 nœuds-noms pour 2 relations — l’économie mesurée du mécanisme alternatif. Toutes ces valeurs concordent avec le twin Python aux vocabulaires d’annotation près (37 vs 35 : deux annotations since de plus côté Python), et c’est la discipline de la paire : mêmes concepts, mêmes scénarios, mesures en regard.
Ce qui doit rester du côté C# spécifiquement : les trois marches d’API franchies ici — nœuds-réifications URI nommés (adressables) plutôt que blank nodes, littéraux typés en culture invariante (sinon le xsd:double casse silencieusement en culture française), identité des graphes nommés par nœud-nomGraph(IRefNode) (le piège de migration 3.x). Trois détails qui ne touchent pas au modèle RDF — et c’est le point : le modèle, lui, est exactement le même des deux côtés de la paire.