# Visual Index v1 ## Problème et frontière Le Visual Index transforme une collection homogène de `FeatureSet` READY en candidats de recherche. Il consomme exclusivement `feature_set_id` et les descripteurs ORB lus par le Feature Reader. Il ne fait ni matching final, ni ratio test, ni vérification géométrique. ## Choix algorithmique La v1 utilise un LSH binaire déterministe à six tables. Chaque table extrait 24 positions distinctes des 256 bits ORB. La position v1 est `(41*table + 11*bit) mod 256`; 11 étant premier avec 256, les 24 positions d'une table sont distinctes. Une clé est `(table_id, key24)`. Des descripteurs proches en Hamming ont une probabilité élevée de collision dans au moins une table, sans conversion flottante. Alternatives évaluées : - le hash exact est très compact et déterministe, mais son rappel s'effondre dès qu'un descriptor varie d'un bit ; - le multi-index hashing avec sous-chaînes et multiprobes offre des garanties Hamming intéressantes, mais le nombre de postings/probes nécessaire au rappel utile d'ORB est trop élevé pour une v1 bornée ; - FLANN-LSH masque son format, ses allocations et sa stabilité de sérialisation, ce qui nuit à la reprise et à l'audit ; - BoW/IVF donne un bon retrieval image, mais impose vocabulaire, entraînement, identité et politique de mise à jour avant de pouvoir être incrémental ; - HNSW et FAISS ajoutent une dépendance et un état mutable complexes sans avantage décisif à quelques milliers d'images. Ce LSH n'est pas un matcher. Il privilégie une base déterministe, segmentable et contrôlable. Une évolution de la sélection de bits exige une nouvelle `visual_index_version`. ## Identité et configuration Le kind est `orb-lsh`, version 1. Un index contient exclusivement des Feature Sets de même `descriptor_type`, dimension, `extractor_kind`, version et `parameter_fingerprint`. Sa configuration v1 contient : - `table_count=6` ; - `key_bits=24` ; - `max_features_per_set` entre 1 et 1024, défaut 512 ; - `max_bucket_postings` entre 1 et 4096, défaut 256 ; - `max_segments=256` ; - `max_feature_sets_per_segment=16`. Le fingerprint de paramètres est SHA-256 des 32 octets canoniques `L3DVICF1`, version et cinq entiers little-endian. Aucun padding, JSON, locale ou endianness hôte n'intervient. `visual_index_id` est une identité SQLite `AUTOINCREMENT`, jamais réutilisée après publication. ## Échantillonnage Au plus `max_features_per_set` features sont indexées. La sélection v1 retient le préfixe de `feature_index` croissant. Les postings conservent l'indice original. Un Feature Set vide est membre valide sans posting. Le build Task peut lire en parallèle jusqu'à douze Feature Files, avec un reader et une tranche de 256 descripteurs privés par participant effectivement admis. Chaque Feature Set écrit dans une tranche privée de la capacité de postings déjà réservée pour le segment ; le propriétaire compacte ensuite les tranches dans l'ordre de sélection et applique seul l'ordre total persistant. ## Segments introduits en Project Database v6, conservés en v7 Un index logique possède des segments immuables READY. Chaque update publie un segment de un à seize nouveaux Feature Sets, puis ajoute atomiquement segments et memberships. `UNIQUE(visual_index_id,feature_set_id)` assure l'idempotence. Une recherche copie au début la liste bornée des segments READY, relâche le mutex DB, puis lit ce snapshot. Un segment commité au milieu sera visible à la requête suivante. SQLite conserve les tables `visual_indexes`, `visual_index_segments`, `visual_index_memberships` et `visual_index_update_tasks`. Les gros postings restent hors DB. La configuration et chaque membership sont immuables. La compaction est `NOT_YET_WIRED`; au-delà de 256 segments une update est refusée avec `LARDON3D_VISUAL_INDEX_LIMIT`. Avec seize membres par segment, la capacité v1 est donc exactement 4096 Feature Sets par index. Le refus ne publie ni segment ni membership et l'index existant reste requêtable. Le DDL v6 exact est `schema_visual_v6` dans `src/project_db.c`. Il impose `AUTOINCREMENT` aux index/segments, les uniques `(visual_index_id,generation)`, `(visual_index_id,sha256)` et `(visual_index_id,feature_set_id)`, ainsi que les FKs vers index, Feature Set, segment et tâche. Les CHECKS bornent tables 1..32, bits 8..32, sampling 1..1024, bucket 1..4096, membres segment 1..16 et durabilité 0..1. La migration entière reste sous `BEGIN IMMEDIATE` et possède une injection de rollback v6. ## Segment File v1 Le fichier est little-endian et ne sérialise aucune structure C. Layout : | Offset | Taille | Champ | |---:|---:|---| | 0 | 8 | magic `L3DVIDX\0` | | 8 | 4 | format version 1 | | 12 | 4 | header size 128 | | 16 | 4 | table count | | 20 | 4 | key bits | | 24 | 8 | posting count | | 32 | 8 | member count | | 40 | 8 | postings offset, 128 | | 48 | 8 | total size | | 56 | 32 | index parameter fingerprint | | 88 | 32 | feature parameter fingerprint | | 120 | 8 | réservés, zéro | Chaque posting fait 24 octets : `table_id:u32`, `key24:u32`, `feature_set_id:u64`, `feature_index:u32`, réservé zéro `u32`. L'ordre est `table_id`, clé, Feature Set, feature index. Le fichier exact est SHA-256 et vit sous `assets/visual-index/<2 hex>/`. Publication : temporaire local, écriture, `fsync`, hash, `link` sans écrasement, validation d'une adoption concurrente, `fsync` du répertoire, puis transaction DB. Un échec après publication peut laisser un orphelin mais jamais un segment READY partiel. La durabilité distingue `DURABLE` et `PUBLISHED_NOT_DURABLE`. Le reader vérifie le SHA avant le parsing. Un fichier au SHA et aux métadonnées cohérents mais portant une version future produit `UNSUPPORTED_VERSION`; les comptes et produits d'offset invalides produisent `CORRUPT` avant allocation, conversion ou lecture de posting. ## Recherche, score et bornes L'API est centrée sur `(visual_index_id, query_feature_set_id)`. Elle accepte `ANY_SCANSET`, `SAME_SCANSET` ou `OTHER_SCANSETS`, l'exclusion du même asset, un minimum de preuves et `top_k` entre 1 et 256. Elle ne retourne jamais le Feature Set ni l'image de requête. Une preuve est un `feature_index` de requête distinct ayant au moins une collision avec le candidat. Plusieurs tables, postings ou descriptors du candidat ne multiplient pas cette preuve. Le score final vaut `evidence_count / sampled_query_feature_count` dans `[0,1]`. Le volume du candidat ne peut donc pas augmenter le score sans preuve distincte. L'ordre est score décroissant, preuves décroissantes, `image_id`, puis `feature_set_id`. La burstiness est bornée par une contribution maximum par feature de requête et candidat. Une première passe additionne la fréquence d'un bucket sur tous les segments du snapshot. Au-delà de `max_bucket_postings`, il est ignoré : un motif très commun ne peut ni allouer une liste géante ni dominer le score. Le reader lit au plus 256 postings par appel. L'accumulateur contient au plus 4096 candidats ; les nouveaux candidats sont ignorés après saturation, de manière déterministe par l'ordre des postings. Aucun cache global n'existe et un seul segment est ouvert à la fois. Deux updates concurrentes peuvent sélectionner le même lot et construire le même asset. La transaction SQLite et les contraintes uniques ne laissent publier qu'un segment et un membership par Feature Set; l'autre update échoue/rejoue en no-op. Une query prend son snapshot de métadonnées avant les lectures et ouvre/ferme un seul segment à la fois, y compris avec 250 à 256 segments : le nombre de descripteurs de fichier reste borné. ## Tâche, reprise et ressources `visual_index.update`, version 1, persiste `visual_index_id` et un curseur `after_feature_set_id`. Une séquence traite au plus seize Feature Sets non indexés, publie et commit un segment, puis checkpoint. Pause et annulation sont coopératives entre lectures et avant publication ; un segment déjà READY reste valide. La reprise recommence au dernier curseur commité et l'unicité des memberships rend le rejeu idempotent. **IMPLEMENTED — parallélisme interne borné.** La Queue exécute toujours un seul callback. L'estimation demande jusqu'à douze threads CPU, un slot I/O, GPU zéro, 8 Mio fixes et 2 Mio par Feature Set, lot 1..16. Le callback compte comme un participant et crée au plus `cpu_threads - 1` enfants. Chaque enfant lit exclusivement des Feature Files immuables et écrit une tranche privée ; il ne touche ni au handle Project DB partagé, ni au fichier de segment, ni au curseur. Tous les enfants sont joints avant tri, sérialisation, publication asset et transaction SQLite. La réduction emploie l'ordre total v1 `table_id,key24,feature_set_id,feature_index`. Le fichier, son SHA-256, le chemin, les memberships, la génération, le fingerprint et les résultats de requête sont donc exactement identiques à un build avec un participant. Une erreur de lecture dans une tranche interdit toute publication ; une création de thread refusée est remplacée par le calcul de cette tranche sur le callback, sans changer la réduction. Le curseur n'avance qu'après la publication transactionnelle du segment, puis le checkpoint existant reste le seul point de reprise Task. `record_batch` reçoit le nombre de Feature Sets réellement commités, la durée réelle et `peak_memory_bytes=0` (inconnue). ## Complexité et limites Pour `D` descriptors échantillonnés, construction et disque sont `O(6D)`. Une requête effectue `O(6Q log P + H)` par segment (`Q<=1024`, `H` hits bornés), pas `O(images²)`. La mémoire build est bornée par les métadonnées, les tranches de 256 descripteurs privées des participants et les postings d'un segment ; les tranches privées partitionnent le buffer de postings existant et ne le dupliquent pas. Chaque participant garde au plus un reader/FD de Feature File. La mémoire query est bornée par 4096 candidats, 256 postings et 256 résultats. À 3700 images et 512 features, environ 11,4 millions de postings sont produits. Un test structurel persiste 50 000 Feature Sets puis confirme la pagination par 16 et le refus propre après 4096 memberships. Un index unique ne couvre donc pas encore 50 000 images : le risque principal est le nombre de segments et les seeks. Une compaction/base+delta ou une évolution v2 sera nécessaire, sans changer les identités durables; elle est `NOT_YET_WIRED`. Les fixtures de validation incluent un Feature Set vide, un crop réel, une rotation de 8 degrés, deux campagnes, un asset source partagé et une attaque de motif répétitif. Ces tests valident le classement de candidats LSH, jamais une compatibilité géométrique. ## Frontière future Le Candidate Pair Generator pourra filtrer sur score et `evidence_count`, puis transmettre `feature_set_id + feature_index` au futur matcher. Le score Visual Index ne constitue jamais une preuve géométrique.