# Séries récurrentes

> Ce qu'est une série (`series[]`), pourquoi son identifiant reste stable, et comment lire `status`, `cadence`, `calendar_pattern` et `history_coverage`.

Une série (`series[]` dans un enrichissement) regroupe plusieurs occurrences économiquement liées dans le temps, au sein de votre workspace : un abonnement, un loyer, un salaire, une pension. `lifecycle` et `identity` ont chacun leur page : [Cycle de vie d'une série](https://developer.noryo.app/fr-FR/transaction-intelligence/series-lifecycle) et [Scission, fusion, supersession](https://developer.noryo.app/fr-FR/transaction-intelligence/split-merge-supersession).

## Une série n'est pas une contrepartie

Une contrepartie est l'autre bout d'une transaction ; une série est un flux qui revient. Une même contrepartie peut porter plusieurs séries : deux abonnements distincts chez Netflix sont deux séries, avec deux `series_id`. Et une série ne se réduit jamais à « toutes les transactions de cette contrepartie ».

Une série est privée : elle vit dans votre workspace et n'alimente jamais le graphe d'entités partagé.

## Un identifiant qui ne dépend pas de la dernière occurrence

`series_id` se dérive de ce qui ne change pas : le workspace, la famille de série, la contrepartie, le sens et la devise. Il ne dépend ni de la dernière occurrence ni du libellé courant. Une occurrence de plus ne crée pas une autre série.

Vous pouvez donc stocker `series_id`. Seule une correction d'identité le remplace ; l'ancien reste lisible, marqué `superseded` dans `identity`, et `membership` relie ce que vous aviez stocké à ce que vous recevez.

## Les quatre types

| `type` | Ce que cela affirme |
| --- | --- |
| `recurring_payment` | Ce paiement revient, rien de plus : un loyer et une échéance de prêt le sont aussi. |
| `subscription` | Une affirmation plus forte, qui exige son propre signal ; sans lui, la réponse reste `recurring_payment`. |
| `recurring_income` | Un revenu qui revient : pension, prestation, loyer perçu. |
| `salary_series` | Une spécialisation de `recurring_income`, pas un synonyme. |

`implies` porte le type plus général : une `subscription` implique `recurring_payment`. `counterparty.semantics.recurring_model` dit ce qui est habituel dans le métier de la contrepartie, pas ce qu'il en est de cette transaction : seul, il ne conclut jamais ; c'est `series[].type` qui tranche. Il n'existe aucune catégorie `subscription` : un abonnement est un modèle de relation commerciale, pas une sorte de dépense.

## `status` : à quel point c'est une série

| `status` | Lecture |
| --- | --- |
| `emerging` | Deux occurrences font émerger une série. Une seule, jamais. |
| `established` | Trois occurrences peuvent l'établir. |
| `uncertain` | Non décrit par le contrat au-delà de son nom ; son exemple le montre sur une cadence `irregular_recurring` (`cadence_detail.strength` à `0`). |
| `broken` | Se calcule au moment de la lecture : une série devient en retard par le seul passage du temps, sans qu'aucune écriture n'ait lieu. |

`status` ne dit ni ce qui arrive à la série maintenant (`lifecycle.state`) ni si son nom est toujours le bon (`identity.status`).

Une première occurrence n'ouvre aucune série : `series[]` est vide et `relationships_policy.unresolved` porte une entrée `type = series` avec `outcome` et `occurrences` (`single_occurrence` et 1 dans l'exemple du contrat, `no_series_key` et 0 dans son exemple non résolu). Les autres issues sont listées dans la [Référence](https://developer.noryo.app/fr-FR/transaction-intelligence/reference).

## Cadence et ancrage : deux questions différentes

`cadence` dit à quelle fréquence l'événement se répète : `weekly`, `biweekly`, `monthly`, `quarterly`, `yearly`, `irregular_recurring` ou `unknown`. Elle se lit au calendrier, pas en jours secs : le 31 janvier et le 28 février sont à un mois d'intervalle. Une récurrence de 28 jours n'est pas mensuelle : son jour recule à chaque cycle, et elle est rangée sous `irregular_recurring`. `cadence_detail.strength` est la part des cycles attendus qui portent une occurrence ; `0` signifie « aucune hypothèse calendaire retenue », pas « faible ».

`calendar_pattern` répond à une autre question : où, dans chaque période, la série tend à tomber. Une série peut être franchement mensuelle en flottant entre le 1er et le 6 ; sans ce bloc, `monthly` se lirait comme « le même jour chaque mois ».

| `calendar_pattern.type` | Ce que cela décrit |
| --- | --- |
| `fixed_day_of_month` | Toujours autour du même jour. |
| `month_end` | En fin de mois, quel que soit le nombre de jours. |
| `last_business_day` | Le dernier jour ouvré, cas fréquent des salaires. |
| `business_day_adjusted` | Un jour nominal déplacé hors du week-end. |
| `fixed_weekday` | Toujours le même jour de la semaine. |
| `flexible_calendar_window` | Une occurrence par période, sans jour stable. |
| `unknown` | Rien d'identifiable. |

Les jours fériés ne sont pas modélisés : un report de férié est absorbé par la tolérance de la fenêtre, pas reconnu. C'est aussi l'ancrage qui donne sa largeur à `lifecycle.expected_next_window`.

**Schéma `CalendarPattern`**

Où, dans chaque période, la série tend à tomber — une question différente de `cadence`. Une série peut être franchement mensuelle en flottant entre le 1er et le 6 : sans ce champ, « monthly » se lirait comme « le même jour chaque mois », ce qui est faux pour la majorité des séries réelles. C'est aussi ce qui donne sa largeur à `expected_next_window` : un prélèvement toujours au 5 justifie trois jours, un loyer qui flotte en justifie huit. Appliquer la même fenêtre aux deux ferait déclarer « en retard » une série parfaitement normale.

| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `type` | string | — | `fixed_day_of_month` : toujours autour du même jour. `month_end` : en fin de mois, quel que soit le nombre de jours — un prélèvement du 31 janvier, 28 février, 31 mars est un seul motif. `last_business_day` : le dernier jour ouvré, cas fréquent des salaires. `business_day_adjusted` : un jour nominal déplacé hors du week-end (samedi → vendredi, dimanche → lundi). Nous NE modélisons pas les jours fériés : ni français, ni target, ni bancaires. Un report de férié est absorbé par la tolérance de la fenêtre, pas reconnu — prétendre connaître un calendrier que nous n'avons pas serait pire que de l'ignorer. `fixed_weekday` : toujours le même jour de la semaine. `flexible_calendar_window` : une occurrence par période, sans jour stable. `unknown` : rien d'identifiable. Valeurs : `fixed_day_of_month`, `month_end`, `last_business_day`, `business_day_adjusted`, `fixed_weekday`, `flexible_calendar_window`, `unknown`. |
| `nominal_day` | integer \| null | — | — |
| `weekday` | integer \| null | — | — |
| `day_range` | array<integer> \| null | — | — |
| `weekend_shifts` | integer \| null | — | — |
| `strength` | number · 0–1 | — | Une constante par type d'ancrage, ou une formule linéaire sur l'étendue des jours pour `fixed_day_of_month` et `flexible_calendar_window`. `0` pour `unknown`. Un 0,33 `fixed_day_of_month` et un 0,3 `flexible_calendar_window` ne se comparent pas — et `flexible_calendar_window` change de règle avec la cadence (constante 0,3 en hebdomadaire, `1 - etendue/10` en mensuel). |
| `calibration` | string | — | Toujours `non_calibree` dans cette version, et c'est une contrainte, pas une convention : la valeur est un vocabulaire en base, les colonnes qui la portent y font référence, et une garde refuse tout littéral hors vocabulaire chez chaque producteur. Le jour où une calibration réelle existe, elle entrera par une migration déclarée, avec le corpus qui la défend. Valeurs : `non_calibree`. |

> **Note** `cadence_detail.strength` (classe `calendar_fill`) et `calendar_pattern.strength` (classe `anchor_stability`) ne sont pas des probabilités : chacun ne se compare qu'entre séries de même cadence, et de même type d'ancrage pour le second. Voir [Sémantique des scores](https://developer.noryo.app/fr-FR/transaction-intelligence/score-semantics).

## Montants et prochaine échéance

`amount_behavior` n'exige pas l'égalité exacte : une facture d'électricité varie, un salaire aussi. `kind` vaut `fixed`, `narrow_variable` ou `broadly_variable` ; `min_minor_units` et `max_minor_units` sont des chaînes en unités mineures, jamais des nombres JSON.

`expected_next_at` est facultatif, et c'est voulu : absent tant que la série n'est pas établie ou que la cadence est irrégulière. Ce n'est pas une API de prévision.

## Ce que la mémoire permet de conclure

`history_coverage` distingue deux mémoires.

| Champ | Sens |
| --- | --- |
| `raw_days` | Durée de vie de la transaction brute ; passé ce délai, elle est supprimée et rien ne la rend. `retention_limit_days` est identique, conservé pour compatibilité. |
| `derived_days` | Durée de vie de ce que nous avons appris d'une série, comptée depuis sa dernière occurrence. À l'échéance, une occurrence ultérieure ouvre une série neuve. |
| `observed_days` | L'étendue réellement observée, de la première à la dernière occurrence connue. |
| `sufficiency` | Une seule question : peut-on croire la cadence ? `sufficient`, `limited` ou `insufficient`. |
| `first_occurrence_bounded` | Une autre question : sait-on quand cela a commencé ? `first_occurrence_reason` dit pourquoi. |

Une transaction purgée reste purgée, mais la série se souvient qu'elle a eu lieu : c'est ce qui rend une cadence mensuelle confirmable alors que la transaction de janvier n'existe plus.

`insufficient` signifie « notre mémoire est trop courte pour en juger », jamais « il n'y a pas d'occurrence plus ancienne ». `first_observed_on` est le premier jour où nous avons observé la série, pas le jour où elle a commencé (il n'existe aucun champ `started_at`) ; `may_predate_observation` vaut `true` quand elle a pu commencer avant nous.

> **Attention** Une série annuelle ne peut pas s'établir dans la fenêtre observable : `status` reste `emerging`. Lisez `sufficiency` et `observed_days` plutôt que de conclure de `occurrence_count` seul.

## `match_strength` et `evidence`

`match_strength.value` n'est pas une probabilité : c'est la part de cinq critères hétérogènes présents (`corroborations.present` sur `corroborations.possible`). Ce n'est pas le même nombre que `relationships[].match_strength`, et il ne se compare qu'entre séries de même `detector_version`. `evidence[]` liste les signaux qui fondent la série (`occurrences` y porte un entier).

## Exemple

Une série d'abonnement établie, extrait de l'exemple du contrat :

```json
{
  "series_id": "ser_6ba7b810-9dad-11d1-80b4-00c04fd430c8",
  "type": "subscription",
  "implies": ["recurring_payment"],
  "status": "established",
  "cadence": "monthly",
  "cadence_detail": { "type": "monthly", "strength": 1, "calibration": "non_calibree" },
  "calendar_pattern": {
    "type": "fixed_day_of_month", "nominal_day": 5, "weekday": null,
    "day_range": [4, 6], "weekend_shifts": 0, "strength": 0.67, "calibration": "non_calibree"
  },
  "occurrence_count": 6,
  "amount_behavior": { "kind": "fixed", "min_minor_units": "1349", "max_minor_units": "1349", "currency": "EUR" },
  "first_observed_on": "2026-04-05",
  "last_observed_on": "2026-09-05",
  "may_predate_observation": true,
  "expected_next_at": "2026-10-05T00:00:00Z",
  "match_strength": { "value": 1, "calibration": "non_calibree", "corroborations": { "present": 5, "possible": 5 } },
  "evidence": [
    { "code": "stable_counterparty", "value": "entite:b83d1756-258c-4541-adc7-54eec83dbd3c" },
    { "code": "occurrences", "value": 6 },
    { "code": "cadence", "value": "monthly" },
    { "code": "amount_behavior", "value": "fixed" },
    { "code": "subscription_category" }
  ],
  "history_coverage": {
    "raw_days": 30, "derived_days": 400, "observed_days": 153, "retention_limit_days": 30,
    "occurrence_count": 6, "cadence": "monthly", "sufficiency": "sufficient",
    "first_occurrence_bounded": true
  }
}
```

**Schéma `Series`**

Plusieurs occurrences économiquement liées dans le temps, dans CE workspace. Une série privée ne devient jamais une vérité du graphe d'entités.

| Champ | Type | Requis | Description |
| --- | --- | --- | --- |
| `series_id` | string | oui | Stable. Il se dérive de ce qui ne change pas — workspace, famille, contrepartie, sens, devise — jamais de la dernière occurrence ni du libellé courant. Une occurrence de plus ne crée pas une autre série. Motif : `^ser_[0-9a-f-]{36}$`. |
| `type` | string | oui | `recurring_payment` dit « ce paiement revient », et rien de plus : un loyer et une échéance de prêt le sont aussi. `subscription` est une affirmation plus forte et exige son propre signal ; sans lui, la réponse reste `recurring_payment`. De même `recurring_income` couvre pension, prestation et loyer perçu — `salary_series` en est une spécialisation, pas un synonyme. Valeurs : `recurring_payment`, `subscription`, `recurring_income`, `salary_series`. |
| `implies` | array<string> | — | Le type plus général que celui-ci spécialise, s'il existe. |
| `status` | string | oui | Deux occurrences font émerger, trois peuvent établir, une seule jamais. `broken` se calcule au moment de la lecture : une série devient en retard par le seul passage du temps, sans qu'aucune écriture n'ait lieu. Valeurs : `emerging`, `established`, `uncertain`, `broken`. |
| `cadence` | string | oui | Lue au calendrier, pas en jours secs : le 31 janvier et le 28 février sont à un mois d'intervalle, et un salaire au dernier jour ouvré glisse. Valeurs : `weekly`, `biweekly`, `monthly`, `quarterly`, `yearly`, `irregular_recurring`, `unknown`. |
| `occurrence_count` | integer | oui | — |
| `amount_behavior` | object | oui | L'égalité exacte n'est pas exigée : une facture d'électricité varie, un salaire aussi. Montants en unités mineures, en chaîne — jamais un nombre JSON. |
| `amount_behavior.kind` | string | — | Valeurs : `fixed`, `narrow_variable`, `broadly_variable`. |
| `amount_behavior.min_minor_units` | string | — | — |
| `amount_behavior.max_minor_units` | string | — | — |
| `amount_behavior.currency` | string | — | — |
| `first_seen` | string · date-time | — | — |
| `last_seen` | string · date-time | — | — |
| `expected_next_at` | string · date-time \| null | — | Facultative, et c'est le point. Absente tant que la série n'est pas établie ou que la cadence est irrégulière : rendre une date sur deux observations faibles la ferait passer pour une prévision. Ce n'est pas une API de prévision. |
| `match_strength` | object | oui | Ce n'est pas une probabilité : c'est la part des signaux corroborants présents sur ceux que ce type sait produire. Et ce n'est pas le même nombre que `relationships[].match_strength` : même nom, autre dénominateur, autres signaux. Cinq critères hétérogènes sur cinq — cadence calendaire, montant fixe ou étroit, au moins trois occurrences, entité connue, type spécialisé. Ils ne s'additionnent qu'ici. |
| `match_strength.value` | number · 0.001–1 | oui | — |
| `match_strength.calibration` | string | oui | Toujours `non_calibree` dans cette version, et c'est une contrainte, pas une convention : la valeur est un vocabulaire en base, les colonnes qui la portent y font référence, et une garde refuse tout littéral hors vocabulaire chez chaque producteur. Le jour où une calibration réelle existe, elle entrera par une migration déclarée, avec le corpus qui la défend. Valeurs : `non_calibree`. |
| `match_strength.corroborations` | object | oui | — |
| `match_strength.corroborations.present` | integer | oui | — |
| `match_strength.corroborations.possible` | integer | oui | Valeurs : `5`. |
| `evidence` | array<object> | — | Les signaux qui fondent la série. `value` est une chaîne ou un entier selon le code (`occurrences` porte un entier). |
| `evidence[].code` | string | oui | — |
| `evidence[].value` | string \| integer \| null | — | — |
| `lifecycle` | SeriesLifecycle | — | Un axe différent de `status`. `status` dit à quel point nous croyons que c'est une série ; `lifecycle` dit ce qui lui arrive maintenant. Une série très bien établie peut avoir cessé de prélever, et une série fraîche peut être parfaitement vivante. Et aucun état ne se conclut d'une absence sans couverture : si la source n'était pas observée de façon complète sur la fenêtre attendue, l'état est `unknown`, pas `late`. « Je n'ai rien vu » ne devient jamais « il n'y a rien eu » — c'est la différence entre un client qui a cessé d'envoyer et un abonnement qui a cessé de prélever. |
| `lifecycle.state` | string | oui | `active` : l'échéance attendue est arrivée, ou n'est pas encore échue. `late` : une échéance manque ET le compte était observé. `inactive` : plusieurs échéances consécutives manquent sous observation complète — la série ne prélève plus, sans que nous sachions pourquoi. `ended` : réservé à une preuve terminale explicite. Aucune accumulation d'absences ne produit cet état : un prélèvement qui s'arrête peut être une résiliation, une suspension, une carte expirée ou un échec de prélèvement, et nous ne devinons pas laquelle. `unknown` : nous ne pouvons pas conclure, et `reason` dit pourquoi. Valeurs : `active`, `late`, `inactive`, `ended`, `unknown`. |
| `lifecycle.reason` | string | — | — |
| `lifecycle.last_observed_on` | string · date \| null | — | — |
| `lifecycle.expected_next_window` | object \| null | — | Une fenêtre, pas une date. Un salaire tombe vers les derniers jours ouvrés, un prélèvement SEPA vers le 5. Rendre un instant exact serait une précision que nous n'avons pas, et vous la croiriez. |
| `lifecycle.expected_next_window.from` | string · date | — | — |
| `lifecycle.expected_next_window.to` | string · date | — | — |
| `lifecycle.expected_next_window.tolerance_days` | integer | — | La demi-largeur de la fenêtre, en jours. Plus grand = moins précis. Vient du type d'ancrage, relevé au plancher de la famille de série. |
| `lifecycle.expected_next_window.anchor` | string | — | Le type d'ancrage qui a donné la fenêtre. |
| `lifecycle.expected_next_window.widened_to_family_floor` | boolean | — | `true` si la tolérance a été relevée au plancher de la famille (5 jours pour un abonnement, 6 pour un salaire, 7 sinon). |
| `lifecycle.consecutive_misses` | integer \| null | — | Le compteur n'avance que sous couverture complète. Absent quand rien n'est attendu — ce qui n'est pas la même ignorance que « nous ne regardions pas ». |
| `lifecycle.observation_coverage` | string | — | `not_required` : aucune échéance close, rien n'avait à être observé. `complete` : chaque source de la série était couverte sur la fenêtre attendue — un manque est un manque. `incomplete` : au moins une source ne l'était pas — l'absence ne prouve rien, `state` est `unknown`. `unknown` : les sources sont inconnues ou trop nombreuses pour être vérifiées. Valeurs : `not_required`, `complete`, `incomplete`, `unknown`. |
| `lifecycle.cadence_strength` | number \| null | — | Le même nombre que `cadence_detail.strength`, rendu ici pour que le cycle de vie soit lisible seul. Ce n'est pas un second score. |
| `lifecycle.calendar_anchor` | any | — | Le même objet que `calendar_pattern` (classe `anchor_stability`), rendu ici pour la même raison ; `{ type: unknown }` quand aucun ancrage n'a été inféré. Absent quand aucune échéance n'est attendue. duplicate_of : `series[].calendar_pattern` — ce n'est pas un second score. |
| `lifecycle.evidence` | array<object> | — | Présent pour `ended` : la preuve terminale qui l'a produit. |
| `lifecycle.evidence[].code` | string | — | — |
| `lifecycle.identity_status` | string \| null | — | Présent uniquement quand l'identité de la série a été corrigée, pour dire pourquoi `state` vaut `unknown` : ce n'est pas une ignorance économique, c'est que le verdict appartient désormais aux successeurs. L'état d'identité complet se lit dans `identity`, qui est un axe à part entière ; ce champ n'est qu'un renvoi, et il ne prend jamais la valeur `current`. Valeurs : `superseded`, `null`. |
| `lifecycle.policy_version` | string | oui | — |
| `lifecycle.calibration` | string | oui | Toujours `non_calibree` dans cette version, et c'est une contrainte, pas une convention : la valeur est un vocabulaire en base, les colonnes qui la portent y font référence, et une garde refuse tout littéral hors vocabulaire chez chaque producteur. Le jour où une calibration réelle existe, elle entrera par une migration déclarée, avec le corpus qui la défend. Valeurs : `non_calibree`. |
| `identity` | SeriesIdentity | — | Un troisième axe, et il ne se mélange ni à `status` NI À `lifecycle`. `status` dit à quel point nous croyons que c'est une série ; `lifecycle` dit ce qui lui arrive économiquement ; `identity` dit si son nom est toujours le bon. Une série peut prélever tous les mois et avoir changé d'identité parce que nous avons compris qu'elle était en réalité deux flux, ou deux moitiés d'un seul. ET UN `series_id` n'est jamais réécrit ni réutilisé en silence. Quand nous corrigeons, l'ancien nom subsiste, marqué `superseded`, et il porte ses successeurs. |
| `identity.status` | string | oui | `current` : ce nom est le nom courant de ce flux. `superseded` : ce nom a été remplacé par une correction d'identité. Il reste valide comme référence historique — il ne disparaîtra pas — mais les occurrences futures porteront le nom de ses successeurs. Valeurs : `current`, `superseded`. |
| `identity.superseded_by` | object \| null | — | Après une scission il n'y a pas de redirection unique, et c'est pourquoi ce champ rend une liste. Si nous découvrons qu'une série mélangeait deux flux, aucun des deux successeurs n'est « la suite » de l'ancien : la moitié des occurrences de l'ancien n'est pas dans l'un d'eux. Élire un canonique serait confortable et faux. |
| `identity.superseded_by.reason` | string | — | `split` : cette identité en recouvrait plusieurs, et chacune a désormais la sienne. `merge` : cette identité et une autre étaient le même flux ; toutes deux sont remplacées par une identité neuve. Valeurs : `split`, `merge`. |
| `identity.superseded_by.at` | string · date-time | — | — |
| `identity.superseded_by.evidence` | array<string> | — | Les codes de preuve qui ont autorisé la correction. Jamais un score interne, jamais un identifiant de ligne. |
| `identity.superseded_by.successor_series_ids` | array<string> | — | Un seul après une fusion, plusieurs après une scission. Jamais vide quand `status` vaut `superseded`. |
| `identity.supersedes` | array<string> | oui | Le chemin de retour, et il compte autant que l'autre. Les identités que celle-ci remplace. Sans lui, l'ancien `series_id` que vous aviez stocké ne correspondrait plus à rien de ce que vous recevez, et vous n'auriez aucun moyen de faire le lien. Vide pour une série qui n'est née d'aucune correction. |
| `membership` | SeriesMembership | — | D'où vient cette occurrence, et où est-elle maintenant. Quand une identité est corrigée, l'occurrence change de série : sans ce bloc, vous n'auriez aucun moyen de relier le `series_id` que vous aviez stocké à celui que vous recevez. Vous n'avez jamais à reconstruire le graphe vous-même. |
| `membership.original_series_id` | string \| null | — | La série à laquelle cette occurrence a été rattachée la première fois. Égale à `current_series_id` tant qu'aucune correction n'a eu lieu. Motif : `^ser_[0-9a-f-]{36}$`. |
| `membership.current_series_id` | string \| null | oui | Motif : `^ser_[0-9a-f-]{36}$`. |
| `membership.corrections` | array<object> | oui | Les déplacements successifs, du plus ancien au plus récent. Vide pour une occurrence qui n'a jamais bougé. |
| `membership.corrections[].reason` | string | — | Valeurs : `split`, `merge`. |
| `membership.corrections[].from_series_id` | string \| null | — | Motif : `^ser_[0-9a-f-]{36}$`. |
| `membership.corrections[].to_series_id` | string \| null | — | Motif : `^ser_[0-9a-f-]{36}$`. |
| `membership.corrections[].at` | string · date-time | — | — |
| `first_observed_on` | string · date | — | Le premier jour où **nous** Avons observé cette série — pas le jour où elle a commencé. Une série peut tourner depuis cinq ans alors que nous ne l'observons que depuis trois mois. Il n'existe volontairement aucun champ `started_at` : nous ne le savons pas. |
| `last_observed_on` | string · date | — | — |
| `may_predate_observation` | boolean | — | `true` : notre observation commence avec la première occurrence connue, donc la série a pu commencer avant nous et rien ne le montrera. `false` : la première occurrence connue est antérieure au début de notre observation, donc son commencement est observé et non supposé. |
| `cadence_detail` | object | — | À quelle fréquence l'événement se répète — et rien d'autre. La cadence se déduit de la progression des périodes calendaires, pas des écarts en jours : trois mois consécutifs sont trois mois consécutifs, que les écarts vaillent 28, 30 ou 31. Et une récurrence de 28 jours n'est pas mensuelle : elle tombe une fois par mois pendant onze mois puis deux, et son jour recule à chaque cycle. Nous ne l'appelons pas `monthly` — cette version n'a pas de cadence pour elle, et `irregular_recurring` vaut mieux qu'un faux mensuel. `strength` est la part des cycles attendus qui portent une occurrence. Ce n'est pas une probabilité. |
| `cadence_detail.type` | string | — | — |
| `cadence_detail.strength` | number · 0–1 \| null | — | `0` = aucune hypothèse calendaire retenue, pas « faible ». |
| `cadence_detail.calibration` | string | — | Toujours `non_calibree` dans cette version, et c'est une contrainte, pas une convention : la valeur est un vocabulaire en base, les colonnes qui la portent y font référence, et une garde refuse tout littéral hors vocabulaire chez chaque producteur. Le jour où une calibration réelle existe, elle entrera par une migration déclarée, avec le corpus qui la défend. Valeurs : `non_calibree`. |
| `calendar_pattern` | CalendarPattern | — | Où, dans chaque période, la série tend à tomber — une question différente de `cadence`. Une série peut être franchement mensuelle en flottant entre le 1er et le 6 : sans ce champ, « monthly » se lirait comme « le même jour chaque mois », ce qui est faux pour la majorité des séries réelles. C'est aussi ce qui donne sa largeur à `expected_next_window` : un prélèvement toujours au 5 justifie trois jours, un loyer qui flotte en justifie huit. Appliquer la même fenêtre aux deux ferait déclarer « en retard » une série parfaitement normale. |
| `calendar_pattern.type` | string | — | `fixed_day_of_month` : toujours autour du même jour. `month_end` : en fin de mois, quel que soit le nombre de jours — un prélèvement du 31 janvier, 28 février, 31 mars est un seul motif. `last_business_day` : le dernier jour ouvré, cas fréquent des salaires. `business_day_adjusted` : un jour nominal déplacé hors du week-end (samedi → vendredi, dimanche → lundi). Nous NE modélisons pas les jours fériés : ni français, ni target, ni bancaires. Un report de férié est absorbé par la tolérance de la fenêtre, pas reconnu — prétendre connaître un calendrier que nous n'avons pas serait pire que de l'ignorer. `fixed_weekday` : toujours le même jour de la semaine. `flexible_calendar_window` : une occurrence par période, sans jour stable. `unknown` : rien d'identifiable. Valeurs : `fixed_day_of_month`, `month_end`, `last_business_day`, `business_day_adjusted`, `fixed_weekday`, `flexible_calendar_window`, `unknown`. |
| `calendar_pattern.nominal_day` | integer \| null | — | — |
| `calendar_pattern.weekday` | integer \| null | — | — |
| `calendar_pattern.day_range` | array<integer> \| null | — | — |
| `calendar_pattern.weekend_shifts` | integer \| null | — | — |
| `calendar_pattern.strength` | number · 0–1 | — | Une constante par type d'ancrage, ou une formule linéaire sur l'étendue des jours pour `fixed_day_of_month` et `flexible_calendar_window`. `0` pour `unknown`. Un 0,33 `fixed_day_of_month` et un 0,3 `flexible_calendar_window` ne se comparent pas — et `flexible_calendar_window` change de règle avec la cadence (constante 0,3 en hebdomadaire, `1 - etendue/10` en mensuel). |
| `calendar_pattern.calibration` | string | — | Toujours `non_calibree` dans cette version, et c'est une contrainte, pas une convention : la valeur est un vocabulaire en base, les colonnes qui la portent y font référence, et une garde refuse tout littéral hors vocabulaire chez chaque producteur. Le jour où une calibration réelle existe, elle entrera par une migration déclarée, avec le corpus qui la défend. Valeurs : `non_calibree`. |
| `history_coverage` | object | oui | Ce que nous pouvons voir, et ce que nous ne pouvons pas. `insufficient` signifie « notre mémoire est trop courte pour en juger », jamais « il n'y a pas d'occurrence plus ancienne ». Et il y a deux mémoires, qu'il ne faut pas confondre. `raw_days` (30) dit combien de temps la transaction reste lisible ; `derived_days` (400) dit combien de temps ce que nous avons appris d'une série lui survit. Une transaction purgée reste purgée — cette API ne la ressuscite jamais — mais la série se souvient qu'elle a eu lieu. C'est ce qui rend une cadence mensuelle confirmable alors que la transaction de janvier n'existe plus. `observed_days` est ce que nous avons effectivement vu : un client qui lit « monthly / sufficient » peut vérifier que la conclusion ne sort pas de trente jours. |
| `history_coverage.observed_from` | string · date | — | — |
| `history_coverage.observed_to` | string · date | — | — |
| `history_coverage.raw_days` | integer | — | Durée de vie de la transaction brute. Passé ce délai elle est physiquement supprimée et rien ne la rend. |
| `history_coverage.derived_days` | integer | — | Durée de vie de la mémoire de série, comptée depuis la dernière occurrence. Sans nouvelle occurrence, l'état dérivé est physiquement supprimé à l'échéance et une occurrence ultérieure ouvre une série neuve. |
| `history_coverage.observed_days` | integer | — | Étendue réellement observée, de la première à la dernière occurrence connue. |
| `history_coverage.retention_limit_days` | integer | oui | Conservé pour compatibilité : identique à `raw_days`. |
| `history_coverage.occurrence_count` | integer | oui | — |
| `history_coverage.cadence` | string | — | — |
| `history_coverage.sufficiency` | string | oui | Répond à une seule question : peut-on croire la cadence ? Pas à « sait-on quand ça a commencé », qui est le champ suivant. Valeurs : `sufficient`, `limited`, `insufficient`. |
| `history_coverage.reason` | string | — | — |
| `history_coverage.first_occurrence_reason` | string | — | Dit pourquoi `first_occurrence_bounded` vaut ce qu'il vaut. |
| `history_coverage.first_occurrence_bounded` | boolean | — | Une question différente de `sufficiency`, et qui était confondue avec elle. `true` : notre observation de cette série commence avec la première occurrence que nous connaissons — elle a donc pu commencer avant nous, et rien ne le montrera jamais. `false` : la première occurrence connue est antérieure au début de notre observation, donc le commencement est observé et non supposé. Un import d'historique rend `false` ; une série découverte au fil de l'eau rend `true`. |
| `detector_version` | string | — | — |

## Pour aller plus loin

- [Cycle de vie d'une série](https://developer.noryo.app/fr-FR/transaction-intelligence/series-lifecycle)
- [Couverture d'observation](https://developer.noryo.app/fr-FR/transaction-intelligence/observation-coverage)
- [Scission, fusion, supersession](https://developer.noryo.app/fr-FR/transaction-intelligence/split-merge-supersession)
- [Sémantique des scores](https://developer.noryo.app/fr-FR/transaction-intelligence/score-semantics)
- [Référence](https://developer.noryo.app/fr-FR/transaction-intelligence/reference)

---

Page canonique : https://developer.noryo.app/fr-FR/transaction-intelligence/recurring-series
