Ajoute un nouveau mécanisme: résoudre la référence circulaire

Ce mécanisme permet d'activer le calcul itératif pour trouver la
valeur de la règle qui résout la référence circulaire.

Il est possible pour une règle de se référencer elle-même. Par défaut, le
moteur considère qu'il s'agit d'un cycle non voulu, et renvoie 'null' comme valeur
pour la règle en question, en affichant un avertissement.

Mais dans certains cas, la formule est bonne et le cycle est voulu. La valeur de la
règle attendue est donc celle qui résout l'équation obtenue via la référence cyclique.

Lorsque l'on active cette fonctionnalité, le moteur va procéder par essaie erreur jusqu'à
trouver cette valeur.

Note : la résolution de cycle est coûteuse en temps de calcul. Il faut donc veiller à
ne pas la cumuler avec l'évaluation d'un autre mécanisme coûteux comme l'inversion numérique
par exemple.
pull/1463/head
Johan Girod 2021-04-08 10:37:19 +02:00 committed by Maxime Quandalle
parent 25ca91bf0c
commit 4ca9ee36c2
24 changed files with 328 additions and 46 deletions

View File

@ -109,7 +109,7 @@
"build:stats": "webpack --config webpack.prod.js --profile --json > stats.json",
"build:analyze-bundle": "ANALYZE_BUNDLE=1 yarn run build",
"build:dev": "FR_BASE_URL='http://localhost:5000${path}' EN_BASE_URL='http://localhost:5001${path}' yarn run build",
"clean": "rimraf dist node_modules source/data",
"clean": "rimraf dist node_modules 'source/data/!(versement-transport.json)'",
"test": "yarn test:file \"./{,!(node_modules)/**/}!(webpack).test.{js,ts}\"",
"test:file": "yarn mocha-webpack --webpack-config ./webpack.dev.js --include test/componentTestSetup.js --require mock-local-storage --require test/helpers/browser.js",
"test:bundlesize": "bundlesize",

View File

@ -28,6 +28,8 @@ function buildRuleDependancies(rule: RuleNode): Array<string> {
return node.explanation.amendedSituation.flatMap((s) => fn(s[1]))
case 'reference':
return [...acc, node.dottedName as string]
case 'résoudre référence circulaire':
return []
case 'rule':
// Cycle from parent dependancies are ignored at runtime,
// so we don' detect them statically

View File

@ -114,6 +114,8 @@ const traverseASTNode: TraverseFunction<NodeKind> = (fn, node) => {
return traverseArrayNode(fn, node)
case 'durée':
return traverseDuréeNode(fn, node)
case 'résoudre référence circulaire':
return traverseRésoudreRéférenceCirculaireNode(fn, node)
case 'inversion':
return traverseInversionNode(fn, node)
case 'operation':
@ -261,6 +263,17 @@ const traversePlancherNode: TraverseFunction<'plancher'> = (fn, node) => ({
},
})
const traverseRésoudreRéférenceCirculaireNode: TraverseFunction<'résoudre référence circulaire'> = (
fn,
node
) => ({
...node,
explanation: {
...node.explanation,
valeur: fn(node.explanation.valeur),
},
})
const traversePlafondNode: TraverseFunction<'plafond'> = (fn, node) => ({
...node,
explanation: {

View File

@ -1,24 +1,23 @@
import { AbattementNode } from '../mecanisms/abattement'
import { ApplicableSiNode } from '../mecanisms/applicable'
import { ArrondiNode } from '../mecanisms/arrondi'
import { OperationNode } from '../mecanisms/operation'
import { BarèmeNode } from '../mecanisms/barème'
import { ReferenceNode } from '../reference'
import { RuleNode } from '../rule'
import { TouteCesConditionsNode } from '../mecanisms/condition-allof'
import { UneDeCesConditionsNode } from '../mecanisms/condition-oneof'
import { DuréeNode } from '../mecanisms/durée'
import { GrilleNode } from '../mecanisms/grille'
import { InversionNode } from '../mecanisms/inversion'
import { MaxNode } from '../mecanisms/max'
import { PlafondNode } from '../mecanisms/plafond'
import { MinNode } from '../mecanisms/min'
import { NonApplicableSiNode } from '../mecanisms/nonApplicable'
import { PossibilityNode } from '../mecanisms/one-possibility'
import { OperationNode } from '../mecanisms/operation'
import { ParDéfautNode } from '../mecanisms/parDéfaut'
import { PlafondNode } from '../mecanisms/plafond'
import { PlancherNode } from '../mecanisms/plancher'
import { ProductNode } from '../mecanisms/product'
import { RecalculNode } from '../mecanisms/recalcul'
import { PossibilityNode } from '../mecanisms/one-possibility'
import { RésoudreRéférenceCiruclaireNode } from '../mecanisms/résoudre-référence-circulaire'
import { SituationNode } from '../mecanisms/situation'
import { SommeNode } from '../mecanisms/sum'
import { SynchronisationNode } from '../mecanisms/synchronisation'
@ -26,7 +25,9 @@ import { TauxProgressifNode } from '../mecanisms/tauxProgressif'
import { UnitéNode } from '../mecanisms/unité'
import { VariableTemporelleNode } from '../mecanisms/variableTemporelle'
import { VariationNode } from '../mecanisms/variations'
import { ReferenceNode } from '../reference'
import { ReplacementRule } from '../replacement'
import { RuleNode } from '../rule'
import { Temporal } from '../temporal'
export type ConstantNode = {
@ -57,6 +58,7 @@ export type ASTNode = (
| PlancherNode
| ProductNode
| RecalculNode
| RésoudreRéférenceCiruclaireNode
| SituationNode
| SommeNode
| SynchronisationNode

View File

@ -2,8 +2,8 @@ import Engine, { EvaluationFunction } from '.'
import {
ASTNode,
ConstantNode,
Evaluation,
EvaluatedNode,
Evaluation,
NodeKind,
} from './AST/types'
import { warning } from './error'
@ -53,7 +53,7 @@ function convertNodesToSameUnit(this: Engine, nodes, mecanismName) {
} catch (e) {
warning(
this.options.logger,
this.cache._meta.ruleStack[0],
this.cache._meta.evaluationRuleStack[0],
`Les unités des éléments suivants sont incompatibles entre elles : \n\t\t${
node?.name || node?.rawNode
}\n\t\t${firstNodeWithUnit?.name || firstNodeWithUnit?.rawNode}'`,

View File

@ -12,15 +12,18 @@ import { Rule, RuleNode } from './rule'
import * as utils from './ruleUtils'
import { formatUnit, getUnitKey } from './units'
const emptyCache = () => ({
_meta: { ruleStack: [] },
const emptyCache = (): Cache => ({
_meta: {
parentRuleStack: [],
evaluationRuleStack: [],
},
nodes: new Map(),
})
type Cache = {
_meta: {
ruleStack: Array<string>
parentEvaluationStack?: Array<string>
parentRuleStack: Array<string>
evaluationRuleStack: Array<string>
inversionFail?:
| {
given: string

View File

@ -25,7 +25,7 @@ const evaluateAbattement: EvaluationFunction<'abattement'> = function (node) {
} catch (e) {
warning(
this.options.logger,
this.cache._meta.ruleStack[0],
this.cache._meta.evaluationRuleStack[0],
"Impossible de convertir les unités de l'allègement entre elles",
e
)

View File

@ -1,9 +1,9 @@
import parse from '../parse'
import { EvaluationFunction } from '..'
import { ConstantNode, Unit } from '../AST/types'
import { mergeMissing } from '../evaluation'
import { registerEvaluationFunction } from '../evaluationFunctions'
import { convertNodeToUnit } from '../nodeUnits'
import parse from '../parse'
import { Context } from '../parsePublicodes'
import { ReferenceNode } from '../reference'
import uniroot from '../uniroot'
@ -54,7 +54,6 @@ export const evaluateInversion: EvaluationFunction<'inversion'> = function (
}
const evaluatedInversionGoal = this.evaluate(inversionGoal)
const unit = 'unit' in node ? node.unit : evaluatedInversionGoal.unit
const originalCache = this.cache
const originalSituation = { ...this.parsedSituation }
let inversionNumberOfIterations = 0
@ -63,7 +62,6 @@ export const evaluateInversion: EvaluationFunction<'inversion'> = function (
inversionNumberOfIterations++
this.resetCache()
this.cache._meta = { ...originalCache._meta }
this.parsedSituation[node.explanation.ruleToInverse] = {
unit: unit,
nodeKind: 'unité',
@ -139,9 +137,11 @@ export const evaluateInversion: EvaluationFunction<'inversion'> = function (
// // Uncomment to display the two attempts and their result
// console.table([{ x: x1, y: y1 }, { x: x2, y: y2 }])
// console.log('iteration:', inversionNumberOfIterations)
// console.log('iteration inversion:', inversionNumberOfIterations)
this.cache = originalCache
this.parsedSituation = originalSituation
return {
...node,
unit,

View File

@ -1,4 +1,3 @@
import { last } from 'ramda'
import { EvaluationFunction } from '..'
import { ASTNode } from '../AST/types'
import { warning } from '../error'
@ -28,7 +27,7 @@ const evaluate: EvaluationFunction<'plafond'> = function (node) {
} catch (e) {
warning(
this.options.logger,
this.cache._meta.ruleStack[0],
this.cache._meta.evaluationRuleStack[0],
"L'unité du plafond n'est pas compatible avec celle de la valeur à encadrer",
e
)

View File

@ -26,7 +26,7 @@ const evaluate: EvaluationFunction<'plancher'> = function (node) {
} catch (e) {
warning(
this.options.logger,
this.cache._meta.ruleStack[0],
this.cache._meta.evaluationRuleStack[0],
"L'unité du plancher n'est pas compatible avec celle de la valeur à encadrer",
e
)

View File

@ -44,7 +44,7 @@ const productEffect: EvaluationFunction = function ({
} catch (e) {
warning(
this.options.logger,
this.cache._meta.ruleStack[0],
this.cache._meta.evaluationRuleStack[0],
"Impossible de convertir l'unité du plafond du produit dans celle de l'assiette",
e
)

View File

@ -0,0 +1,109 @@
import { EvaluationFunction } from '..'
import { ASTNode, ConstantNode, Unit } from '../AST/types'
import { registerEvaluationFunction } from '../evaluationFunctions'
import parse from '../parse'
import { Context } from '../parsePublicodes'
import uniroot from '../uniroot'
import { UnitéNode } from './unité'
export type RésoudreRéférenceCiruclaireNode = {
explanation: {
ruleToSolve: string
valeur: ASTNode
}
nodeKind: 'résoudre référence circulaire'
}
export const evaluateRésoudreRéférenceCirculaire: EvaluationFunction<'résoudre référence circulaire'> = function (
node
) {
const originalCache = this.cache
let inversionNumberOfIterations = 0
const evaluateWithValue = (
n: number,
unit: Unit = { numerators: [], denominators: [] }
) => {
inversionNumberOfIterations++
this.resetCache()
this.parsedSituation[node.explanation.ruleToSolve] = {
unit: unit,
nodeKind: 'unité',
explanation: {
nodeKind: 'constant',
nodeValue: n,
type: 'number',
} as ConstantNode,
} as UnitéNode
return this.evaluate(node.explanation.valeur)
}
let nodeValue: number | null | undefined = null
const x0 = 0
let valeur = evaluateWithValue(x0)
const y0 = valeur.nodeValue as number
const unit = valeur.unit
const missingVariables = valeur.missingVariables
let i = 0
if (y0 !== null) {
// The `uniroot` function parameter. It will be called with its `min` and
// `max` arguments, so we can use our cached nodes if the function is called
// with the already computed x1 or x2.
const test = (x: number): number => {
if (x === x0) {
return y0 - x0
}
valeur = evaluateWithValue(x, unit)
const y = valeur.nodeValue
i++
return (y as number) - x
}
const defaultMin = -1_000_000
const defaultMax = 100_000_000
nodeValue = uniroot(test, defaultMin, defaultMax, 1, 30, 2)
}
if (nodeValue === undefined) {
nodeValue = null
this.cache._meta.inversionFail = true
}
if (nodeValue != null) {
originalCache.nodes.forEach((v, k) => this.cache.nodes.set(k, v))
}
console.log('iteration résoudre référence circulaire :', i)
this.cache = originalCache
delete this.parsedSituation[node.explanation.ruleToSolve]
return {
...node,
unit,
nodeValue,
explanation: {
...node.explanation,
valeur,
inversionNumberOfIterations,
},
missingVariables,
}
}
export default function parseRésoudreRéférenceCirculaire(v, context: Context) {
return {
explanation: {
ruleToSolve: context.dottedName,
valeur: parse(v.valeur, context),
},
nodeKind: 'résoudre référence circulaire',
} as RésoudreRéférenceCiruclaireNode
}
parseRésoudreRéférenceCirculaire.nom = 'résoudre la référence circulaire'
registerEvaluationFunction(
'résoudre référence circulaire',
evaluateRésoudreRéférenceCirculaire
)

View File

@ -63,7 +63,7 @@ export function evaluatePlafondUntilActiveTranche(
} catch (e) {
warning(
this.options.logger,
this.cache._meta.ruleStack[0],
this.cache._meta.evaluationRuleStack[0],
`L'unité du plafond de la tranche n°${
i + 1
} n'est pas compatible avec celle l'assiette`,
@ -103,7 +103,7 @@ export function evaluatePlafondUntilActiveTranche(
) {
evaluationError(
this.options.logger,
this.cache._meta.ruleStack[0],
this.cache._meta.evaluationRuleStack[0],
`Le plafond de la tranche n°${
i + 1
} a une valeur inférieure à celui de la tranche précédente`

View File

@ -37,7 +37,7 @@ registerEvaluationFunction(parseUnité.nom, function evaluate(node) {
} catch (e) {
warning(
this.options.logger,
this.cache._meta.ruleStack[0],
this.cache._meta.evaluationRuleStack[0],
"Erreur lors de la conversion d'unité explicite",
e
)

View File

@ -120,7 +120,7 @@ const evaluate: EvaluationFunction<'variations'> = function (node) {
} catch (e) {
warning(
this.options.logger,
this.cache._meta.ruleStack[0],
this.cache._meta.evaluationRuleStack[0],
`L'unité de la branche n° ${
i + 1
} du mécanisme 'variations' n'est pas compatible avec celle d'une branche précédente`,

View File

@ -22,6 +22,7 @@ import plafond from './mecanisms/plafond'
import plancher from './mecanisms/plancher'
import { mecanismProduct } from './mecanisms/product'
import { mecanismRecalcul } from './mecanisms/recalcul'
import résoudreRéférenceCirculaire from './mecanisms/résoudre-référence-circulaire'
import situation from './mecanisms/situation'
import { mecanismSum } from './mecanisms/sum'
import { mecanismSynchronisation } from './mecanisms/synchronisation'
@ -147,6 +148,7 @@ ${e.message}`
}
}
// Chainable mecanisme in their composition order (first one is applyied first)
const chainableMecanisms = [
applicable,
nonApplicable,
@ -156,6 +158,7 @@ const chainableMecanisms = [
plafond,
parDéfaut,
situation,
résoudreRéférenceCirculaire,
abattement,
]
function parseChainedMecanisms(rawNode, context: Context): ASTNode {

View File

@ -1,8 +1,6 @@
import { EvaluatedNode } from './AST/types'
import { InternalError } from './error'
import { registerEvaluationFunction } from './evaluationFunctions'
import { Context } from './parsePublicodes'
import { RuleNode } from './rule'
export type ReferenceNode = {
nodeKind: 'reference'
@ -26,6 +24,7 @@ registerEvaluationFunction('reference', function evaluateReference(node) {
if (!node.dottedName) {
throw new InternalError(node)
}
const explanation = this.evaluate(this.parsedRules[node.dottedName])
return {
...node,

View File

@ -1,6 +1,8 @@
import { ASTNode, EvaluatedNode } from './AST/types'
import { warning } from './error'
import { bonus, mergeMissing } from './evaluation'
import { registerEvaluationFunction } from './evaluationFunctions'
import { capitalise0 } from './format'
import parse, { mecanismKeys } from './parse'
import { Context } from './parsePublicodes'
import { ReferenceNode } from './reference'
@ -10,7 +12,6 @@ import {
ReplacementRule,
} from './replacement'
import { nameLeaf, ruleParents } from './ruleUtils'
import { capitalise0 } from './format'
export type Rule = {
formule?: Record<string, unknown> | string
@ -119,25 +120,53 @@ export default function parseRule(
}
registerEvaluationFunction('rule', function evaluate(node) {
if (this.cache[node.dottedName]) {
return this.cache[node.dottedName]
}
const explanation = { ...node.explanation }
const verifyParentApplicability = !this.cache._meta.ruleStack.includes(
node.dottedName
)
this.cache._meta.ruleStack.unshift(node.dottedName)
let parent: EvaluatedNode | null = null
if (explanation.parent && verifyParentApplicability) {
parent = this.evaluate(explanation.parent) as EvaluatedNode
if (explanation.parent) {
if (this.cache._meta.parentRuleStack.includes(node.dottedName)) {
parent = { nodeValue: null } as EvaluatedNode
} else {
this.cache._meta.parentRuleStack.unshift(node.dottedName)
parent = this.evaluate(explanation.parent) as EvaluatedNode
this.cache._meta.parentRuleStack.shift()
}
explanation.parent = parent
}
let valeur: EvaluatedNode | null = null
if (!parent || parent.nodeValue !== false) {
valeur = this.evaluate(explanation.valeur) as EvaluatedNode
if (
this.cache._meta.evaluationRuleStack.filter(
(dottedName) => dottedName === node.dottedName
).length > 15 // I don't know why this magic number, but below, cycle are detected "too early", which leads to blank value in brut-net simulator
) {
warning(
this.options.logger,
node.dottedName,
`
Un cycle a été détecté dans lors de l'évaluation de cette règle.
Par défaut cette règle sera évaluée à 'null'.
Pour indiquer au moteur de résoudre la référence circulaire en trouvant le point fixe
de la fonction, il vous suffit d'ajouter l'attribut suivant niveau de la règle :
${node.dottedName}:
"résoudre la référence circulaire: oui"
...
`
)
valeur = { nodeValue: null } as EvaluatedNode
} else {
this.cache._meta.evaluationRuleStack.unshift(node.dottedName)
valeur = this.evaluate(explanation.valeur) as EvaluatedNode
this.cache._meta.evaluationRuleStack.shift()
}
explanation.valeur = valeur
}
// if (valeur.nodeValue === '') {
const evaluation = {
...node,
explanation,
@ -148,7 +177,5 @@ registerEvaluationFunction('rule', function evaluate(node) {
),
...(valeur && 'unit' in valeur && { unit: valeur.unit }),
}
this.cache._meta.ruleStack.shift()
this.cache[node.dottedName] = evaluation
return evaluation
})

View File

@ -109,7 +109,9 @@ export default function uniroot(
if ((fb > 0 && fc > 0) || (fb < 0 && fc < 0)) {
;(c = a), (fc = fa) // Adjust c for it to have a sign opposite to that of b
}
if (Math.abs(fb) < errorTol) {
return b
}
if (Math.abs(fb) < acceptableErrorTol) {
fallback = b
}

View File

@ -0,0 +1,68 @@
fx:
x:
résoudre la référence circulaire: oui
valeur: fx
exemples:
- nom: affine
situation:
fx: 200 - x
valeur attendue: 100
- nom: quadratique
situation:
fx: 0.2 * x * x - 400 * x + 500
valeur attendue: 2003.743
# CF https://www.wolframalpha.com/input/?i=x%3D0.2x%C2%B2-400x%2B500
CA:
unité:
plancher: 0
formule:
inversion numérique:
avec:
- net
net:
résoudre la référence circulaire: oui
unité:
formule: CA - 50% * net
net après impôt:
formule: 80% * net
unité:
cycle avec inversion et situation vide:
exemples:
- nom: CA
situation:
cycle avec inversion et situation vide: CA
valeur attendue: null
# - nom: net
# situation:
# cycle avec inversion et situation vide: net
# valeur attendue: null
# - nom: net après impôt
# situation:
# cycle avec inversion et situation vide: net après impôt
# valeur attendue: null
cycle avec la règle à inverser fixée dans la situation:
valeur: net
exemples:
- situation:
CA: 10000
valeur attendue: 6666.666
cycle avec la règle du cycle fixée dans la situation:
valeur: CA
exemples:
- situation:
net: 1000
valeur attendue: 1500
# cycle avec une règle reliée fixée dans la situation:
# valeur: net
# exemples:
# - situation:
# net après impôt: 8000
# valeur attendue: 10000

View File

@ -494,6 +494,7 @@ synchronisation:
n'est pas stable. Se référer aux exemples existants.
inversion numérique:
chainable: oui
description: >-
Il est souhaitable de rédiger les règles de calcul
en publicodes de la même façon qu'elles sont décrites dans la loi ou les
@ -523,3 +524,37 @@ inversion numérique:
(calculée ou saisie), et procéder à l'inversion décrite plus haut à partir
de celle-ci. Sinon, ces possibilités d'inversions seront listées comme
manquantes.
résoudre la référence circulaire:
description: |
Active le calcul itératif pour trouver la valeur de la règle qui résout
la référence circulaire.
Il est possible pour une règle de se référencer elle-même. Par défaut, le
moteur considère qu'il s'agit d'un cycle non voulu, et renvoie 'null' comme valeur
pour la règle en question, en affichant un avertissement.
Mais dans certains cas, la formule est bonne et le cycle est voulu. La valeur de la
règle attendue est donc celle qui résout l'équation obtenue via la référence cyclique.
Lorsque l'on active cette fonctionnalité, le moteur va procéder par essai-erreur jusqu'à
trouver cette valeur.
Note : la résolution de cycle est coûteuse en temps de calcul. Il faut donc veiller à
ne pas la cumuler avec l'évaluation d'un autre mécanisme coûteux comme l'inversion numérique
par exemple.
exemples:
base: >-
x:
valeur: 4 * x - 5
résoudre la référence circulaire: oui
calcul du revenu professionnel: >-
chiffre d'affaires: 10000 €/an
cotisations: 25% * revenu professionnel
revenu professionnel:
valeur: chiffre d'affaires - cotisations
résoudre la référence circulaire: oui

View File

@ -1,8 +1,10 @@
import { ConstantNode, Leaf } from './mecanisms/common'
import { useContext } from 'react'
import { EngineContext } from './contexts'
import Abattement from './mecanisms/Abattement'
import ApplicableSi from './mecanisms/Applicable'
import Arrondi from './mecanisms/Arrondi'
import Barème from './mecanisms/Barème'
import { ConstantNode, Leaf } from './mecanisms/common'
import Composantes from './mecanisms/Composantes'
import Durée from './mecanisms/Durée'
import Grille from './mecanisms/Grille'
@ -19,6 +21,7 @@ import Recalcul from './mecanisms/Recalcul'
import Replacement from './mecanisms/Replacement'
import ReplacementRule from './mecanisms/ReplacementRule'
import Rule from './mecanisms/Rule'
import RésoudreRéférenceCirculaire from './mecanisms/RésoudreRéférenceCirculaire'
import Situation from './mecanisms/Situation'
import Somme from './mecanisms/Somme'
import Synchronisation from './mecanisms/Synchronisation'
@ -28,8 +31,6 @@ import UneDeCesConditions from './mecanisms/UneDeCesConditions'
import UnePossibilité from './mecanisms/UnePossibilité'
import Unité from './mecanisms/Unité'
import Variations from './mecanisms/Variations'
import { useContext } from 'react'
import { EngineContext } from './contexts'
const UIComponents = {
constant: ConstantNode,
@ -61,6 +62,7 @@ const UIComponents = {
'toutes ces conditions': ToutesCesConditions,
'une de ces conditions': UneDeCesConditions,
'une possibilité': UnePossibilité,
'résoudre référence circulaire': RésoudreRéférenceCirculaire,
unité: Unité,
'variable temporelle': () => '[variable temporelle]',
variations: Variations,

View File

@ -0,0 +1,19 @@
import Explanation from '../Explanation'
import { Mecanism } from './common'
export default function MecanismRésoudreRéférenceCirculaire({ explanation }) {
return (
<Mecanism
name="résoudre la référence circulaire"
value={explanation.valeur}
>
<p>
{' '}
Cette valeur a été retrouvé en résolvant la référence circulaire dans la
formule ci dessous :{' '}
</p>
<Explanation node={explanation.valeur} />
</Mecanism>
)
}

View File

@ -19,7 +19,6 @@ export default function Rule({ dottedName, engine, language }) {
return <p>Cette règle est introuvable dans la base</p>
}
const rule = engine.evaluate(engine.getRule(dottedName))
const { description, question } = rule.rawNode
const { parent, valeur } = rule.explanation
return (