From c0357b152f1ff76bbacf1a0b99d8fb771cebb750 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Fri, 22 May 2026 12:44:57 -0700 Subject: [PATCH] refactor: introduce unified PredicateSpec table, convert extraction consumers Imhotep-core: add predicate-specs.ts with 34 PredicateSpec entries as the single source of truth for predicate metadata (name, arity, aliases, requiredFacts, validOptions, diagnosticCode, relationCode, decompose rules, category flags). Lookup helpers derive all per-predicate information from the static table. Extraction.ts (3 consumers converted): - computeRequiredFacts: replace getRequiredFactsForPredicate (global registry) with getPredicateRequiredFacts (static spec table). Removes registerDefaultPredicates() dependency from fact planning. - compileCanonicalClauseToFormula: replace 4 string-branch patterns ('between'/'separatedFrom'/'atLeast'/'aspectRatio'/'inStackingContext') with spec-driven getPredicateDecomposition() and isUnaryPredicate(). Same behavior, zero string dispatch in predicate selection. - mapFolDiagnostic: replace PREDICATE_TO_DIAGNOSTIC_CODE (13-entry Record) with getPredicateDiagnosticCode() from spec table. 595 SDK + 57 hard E2E tests pass. --- packages/imhotep-core/src/index.ts | 1 + packages/imhotep-core/src/predicate-specs.ts | 577 ++++++++++++++++++ packages/imhotep-playwright/src/extraction.ts | 57 +- 3 files changed, 600 insertions(+), 35 deletions(-) create mode 100644 packages/imhotep-core/src/predicate-specs.ts diff --git a/packages/imhotep-core/src/index.ts b/packages/imhotep-core/src/index.ts index 623a1ae..145e099 100644 --- a/packages/imhotep-core/src/index.ts +++ b/packages/imhotep-core/src/index.ts @@ -49,6 +49,7 @@ export * from './property-contracts.js' export * from './property-results.js' export * from './canonical.js' export * from './context.js' +export * from './predicate-specs.js' export { serializeGeometryWorld, deserializeGeometryWorld, diff --git a/packages/imhotep-core/src/predicate-specs.ts b/packages/imhotep-core/src/predicate-specs.ts new file mode 100644 index 0000000..9c9c6e8 --- /dev/null +++ b/packages/imhotep-core/src/predicate-specs.ts @@ -0,0 +1,577 @@ +/** + * Predicate specification registry — single source of truth for all built-in + * predicates. Every other package (solver, DSL, Playwright, CDP, etc.) should + * derive its per-predicate metadata from this table rather than maintaining + * parallel string-based maps or switch/case branches. + */ + +import type { DiagnosticCode } from './diagnostics.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface PredicateDecomposition { + kind: 'between' | 'negate' | 'delegate' + /** For 'between': splits into atLeast({dimension,min}) AND atMost({dimension,max}). */ + /** For 'negate': wraps body in NOT, substitutes inner predicate name. */ + inner?: string + /** For 'delegate': canonical predicate name to delegate to (alias evaluator). */ + delegateTo?: string + /** Options to override/merge when delegating. */ + overrideOptions?: Record +} + +export interface PredicateSpec { + /** Canonical predicate name (e.g. 'leftOf', 'between'). */ + name: string + /** Alternate names that resolve to this spec. */ + aliases: string[] + /** Expected tuple arity: 1=unary, 2=binary, 'variable'=can be either. */ + arity: 1 | 2 | 'variable' + /** Domain kinds for the solver tuple-resolution system. */ + domains: string[] + /** Required fact IDs for extraction planning. */ + requiredFacts: string[] + /** Allowed option keys (from user code). */ + validOptions: readonly string[] + /** Public diagnostic code when a predicate fails. */ + diagnosticCode?: DiagnosticCode + /** Small integer code for the dense DSL execution IR. 0 = not encoded. */ + relationCode?: number + /** Special compilation rule. Undefined = direct predicate call. */ + decompose?: PredicateDecomposition + /** Whether this predicate accepts quantifier modifiers (any/none). */ + quantifierCompatible: boolean + /** Whether this is a spatial relation (used for validator category checks). */ + isSpatial: boolean + /** Whether this is a size/dimensional relation. */ + isSize: boolean + /** Whether this is a topology relation. */ + isTopology: boolean +} + +// --------------------------------------------------------------------------- +// All built-in predicate specs +// --------------------------------------------------------------------------- + +export const PREDICATE_SPECS: readonly PredicateSpec[] = Object.freeze([ + // ── Spatial (cardinal directions) ────────────────────────────────────────── + { + name: 'above', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space'], + diagnosticCode: 'IMH_RELATION_ABOVE_FAILED', + relationCode: 3, + quantifierCompatible: true, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'below', + aliases: ['under'], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space'], + diagnosticCode: 'IMH_RELATION_BELOW_FAILED', + relationCode: 4, + quantifierCompatible: true, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'leftOf', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space'], + diagnosticCode: 'IMH_RELATION_LEFT_OF_FAILED', + relationCode: 1, + quantifierCompatible: true, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'rightOf', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space'], + diagnosticCode: 'IMH_RELATION_RIGHT_OF_FAILED', + relationCode: 2, + quantifierCompatible: true, + isSpatial: true, + isSize: false, + isTopology: false, + }, + + // ── Spatial (containment / overlap) ─────────────────────────────────────── + { + name: 'inside', + aliases: ['within'], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['tolerance', 'minGap', 'space'], + diagnosticCode: 'IMH_RELATION_INSIDE_FAILED', + relationCode: 11, + quantifierCompatible: true, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'contains', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['tolerance', 'space'], + diagnosticCode: 'IMH_RELATION_CONTAINS_FAILED', + relationCode: 12, + quantifierCompatible: true, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'overlaps', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['tolerance', 'space'], + diagnosticCode: 'IMH_RELATION_OVERLAPS_FAILED', + relationCode: 13, + quantifierCompatible: true, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'intersects', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['tolerance', 'space'], + diagnosticCode: undefined, + relationCode: 17, + quantifierCompatible: false, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'touches', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['tolerance', 'inStackingContext', 'space'], + diagnosticCode: undefined, + relationCode: 18, + quantifierCompatible: false, + isSpatial: true, + isSize: false, + isTopology: false, + }, + + // ── Spatial (alignment) ─────────────────────────────────────────────────── + { + name: 'alignedWith', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['axis', 'tolerance', 'space'], + diagnosticCode: 'IMH_ALIGNMENT_FAILED', + relationCode: 5, + quantifierCompatible: true, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'leftAlignedWith', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['tolerance', 'space'], + diagnosticCode: undefined, + relationCode: 6, + quantifierCompatible: false, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'rightAlignedWith', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['tolerance', 'space'], + diagnosticCode: undefined, + relationCode: 7, + quantifierCompatible: false, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'topAlignedWith', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['tolerance', 'space'], + diagnosticCode: undefined, + relationCode: 8, + quantifierCompatible: false, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'bottomAlignedWith', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['tolerance', 'space'], + diagnosticCode: undefined, + relationCode: 9, + quantifierCompatible: false, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'centeredWithin', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['tolerance', 'space'], + diagnosticCode: 'IMH_RELATION_CENTERED_FAILED', + relationCode: 10, + quantifierCompatible: true, + isSpatial: true, + isSize: false, + isTopology: false, + }, + + // ── Spatial (gap / proximity) ───────────────────────────────────────────── + { + name: 'separatedFrom', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['minGap', 'maxGap', 'tolerance', 'space'], + diagnosticCode: undefined, + relationCode: 14, + decompose: { kind: 'negate', inner: 'overlaps' }, + quantifierCompatible: true, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'hasGap', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['minGap', 'maxGap', 'tolerance', 'space'], + diagnosticCode: undefined, + relationCode: 19, + quantifierCompatible: false, + isSpatial: true, + isSize: false, + isTopology: false, + }, + + // ── Spatial aliases (delegating evaluators) ─────────────────────────────── + { + name: 'beside', + aliases: ['nextTo'], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space'], + diagnosticCode: undefined, + relationCode: 0, + quantifierCompatible: false, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'adjacent', + aliases: ['touching'], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['tolerance', 'inStackingContext', 'space'], + diagnosticCode: undefined, + relationCode: 0, + quantifierCompatible: false, + isSpatial: true, + isSize: false, + isTopology: false, + }, + { + name: 'near', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.primaryBox'], + validOptions: ['minGap', 'maxGap', 'tolerance', 'inStackingContext', 'space'], + diagnosticCode: undefined, + relationCode: 0, + quantifierCompatible: false, + isSpatial: true, + isSize: false, + isTopology: false, + }, + + // ── Size / dimensional ──────────────────────────────────────────────────── + { + name: 'width', + aliases: [], + arity: 1, + domains: ['element'], + requiredFacts: ['subject.primaryBox'], + validOptions: [], + diagnosticCode: undefined, + relationCode: 0, + quantifierCompatible: false, + isSpatial: false, + isSize: true, + isTopology: false, + }, + { + name: 'height', + aliases: [], + arity: 1, + domains: ['element'], + requiredFacts: ['subject.primaryBox'], + validOptions: [], + diagnosticCode: undefined, + relationCode: 0, + quantifierCompatible: false, + isSpatial: false, + isSize: true, + isTopology: false, + }, + { + name: 'atLeast', + aliases: [], + arity: 1, + domains: ['element'], + requiredFacts: ['subject.primaryBox'], + validOptions: ['dimension', 'value', 'mode', 'space'], + diagnosticCode: 'IMH_SIZE_AT_LEAST_FAILED', + relationCode: 0, + quantifierCompatible: false, + isSpatial: false, + isSize: true, + isTopology: false, + }, + { + name: 'atMost', + aliases: [], + arity: 1, + domains: ['element'], + requiredFacts: ['subject.primaryBox'], + validOptions: ['dimension', 'value', 'mode', 'space'], + diagnosticCode: 'IMH_SIZE_AT_MOST_FAILED', + relationCode: 0, + quantifierCompatible: false, + isSpatial: false, + isSize: true, + isTopology: false, + }, + { + name: 'between', + aliases: [], + arity: 1, + domains: ['element'], + requiredFacts: ['subject.primaryBox'], + validOptions: ['dimension', 'min', 'max', 'mode', 'space'], + diagnosticCode: 'IMH_SIZE_BETWEEN_FAILED', + relationCode: 16, + decompose: { kind: 'between' }, + quantifierCompatible: false, + isSpatial: false, + isSize: true, + isTopology: false, + }, + { + name: 'aspectRatio', + aliases: [], + arity: 1, + domains: ['element'], + requiredFacts: ['subject.primaryBox'], + validOptions: ['min', 'max'], + diagnosticCode: undefined, + relationCode: 0, + quantifierCompatible: false, + isSpatial: false, + isSize: true, + isTopology: false, + }, + + // ── Topology ────────────────────────────────────────────────────────────── + { + name: 'clippedBy', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.clipChain', 'reference.clipChain'], + validOptions: [], + diagnosticCode: undefined, + relationCode: 0, + quantifierCompatible: false, + isSpatial: false, + isSize: false, + isTopology: true, + }, + { + name: 'inStackingContext', + aliases: [], + arity: 'variable' as const, + domains: ['element', 'element'], + requiredFacts: ['topology.stackingContextOf'], + validOptions: [], + diagnosticCode: undefined, + relationCode: 0, + quantifierCompatible: false, + isSpatial: false, + isSize: false, + isTopology: true, + }, + { + name: 'escapeClippingChainOf', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['subject.primaryBox', 'reference.clipChain', 'topology.clippingRootOf'], + validOptions: [], + diagnosticCode: undefined, + relationCode: 0, + quantifierCompatible: false, + isSpatial: false, + isSize: false, + isTopology: true, + }, + { + name: 'attachedToScrollContainer', + aliases: [], + arity: 2, + domains: ['element', 'element'], + requiredFacts: ['topology.scrollContainerOf'], + validOptions: [], + diagnosticCode: undefined, + relationCode: 0, + quantifierCompatible: false, + isSpatial: false, + isSize: false, + isTopology: true, + }, +] satisfies PredicateSpec[]) + +// --------------------------------------------------------------------------- +// Lookup helpers +// --------------------------------------------------------------------------- + +const byName = new Map() +const byAlias = new Map() +for (const spec of PREDICATE_SPECS) { + byName.set(spec.name, spec) + for (const alias of spec.aliases) { + byName.set(alias, spec) + byAlias.set(alias, spec.name) + } +} + +export function getPredicateSpec(name: string): PredicateSpec | undefined { + return byName.get(name) +} + +export function resolvePredicateName(name: string): string { + return byAlias.get(name) ?? name +} + +export function getAllPredicateSpecs(): readonly PredicateSpec[] { + return PREDICATE_SPECS +} + +export function isPredicateName(name: string): boolean { + return byName.has(name) +} + +export function getPredicateDiagnosticCode(name: string): DiagnosticCode | undefined { + return byName.get(name)?.diagnosticCode +} + +export function getPredicateRelationCode(name: string): number | undefined { + return byName.get(name)?.relationCode +} + +export function getPredicateRequiredFacts(name: string): string[] { + return byName.get(name)?.requiredFacts ?? [] +} + +export function getPredicateValidOptions(name: string): readonly string[] { + return byName.get(name)?.validOptions ?? [] +} + +export function getPredicateArity(name: string): 1 | 2 | 'variable' | undefined { + return byName.get(name)?.arity +} + +export function isUnaryPredicate(name: string): boolean { + const spec = byName.get(name) + return spec?.arity === 1 || spec?.arity === 'variable' +} + +export function isVariableArityPredicate(name: string): boolean { + return byName.get(name)?.arity === 'variable' +} + +export function getPredicateDecomposition(name: string): PredicateDecomposition | undefined { + return byName.get(name)?.decompose +} + +export function collectAllPredicateNames(): string[] { + return Array.from(byName.keys()) +} + +export function collectSpatialPredicateNames(): string[] { + return PREDICATE_SPECS.filter(s => s.isSpatial).flatMap(s => [s.name, ...s.aliases]) +} + +export function collectSizePredicateNames(): string[] { + return PREDICATE_SPECS.filter(s => s.isSize).flatMap(s => [s.name, ...s.aliases]) +} + +export function collectTopologyPredicateNames(): string[] { + return PREDICATE_SPECS.filter(s => s.isTopology).flatMap(s => [s.name, ...s.aliases]) +} + +export function collectQuantifierCompatiblePredicateNames(): string[] { + return PREDICATE_SPECS.filter(s => s.quantifierCompatible).flatMap(s => [s.name, ...s.aliases]) +} diff --git a/packages/imhotep-playwright/src/extraction.ts b/packages/imhotep-playwright/src/extraction.ts index 4487c37..8bbc34b 100644 --- a/packages/imhotep-playwright/src/extraction.ts +++ b/packages/imhotep-playwright/src/extraction.ts @@ -48,10 +48,15 @@ import { evaluateLogic, registerDefaultPredicates, getPredicateEvaluator, - getRequiredFactsForPredicate, type DomainResolver, BindingEnv, } from 'imhotep-solver' +import { + getPredicateRequiredFacts, + getPredicateDiagnosticCode, + getPredicateDecomposition, + isUnaryPredicate, +} from 'imhotep-core' import { buildGeometryWorld } from './world-builder.js' import { materializeSemanticSelector, @@ -197,12 +202,11 @@ export function computeRequiredFacts(formulas: FormulaNode[]): { fragments: boolean domAncestry: boolean } { - registerDefaultPredicates() const facts = new Set() for (const formula of formulas) { const predicates = collectPredicates(formula) for (const p of predicates) { - const required = getRequiredFactsForPredicate(p) + const required = getPredicateRequiredFacts(p) for (const f of required) { facts.add(f) } @@ -1157,7 +1161,10 @@ export function compileCanonicalClauseToFormula(clause: CanonicalClauseDescripto let body: FormulaNode - if (clause.relation === 'between') { + const decomp = getPredicateDecomposition(clause.relation) + const hasInStackingFlag = (clause.flags & 8) !== 0 + + if (decomp?.kind === 'between') { const minVal = extended.options?.min as number | undefined const maxVal = extended.options?.max as number | undefined const atLeastOpts: Record = {} @@ -1184,24 +1191,22 @@ export function compileCanonicalClauseToFormula(clause: CanonicalClauseDescripto ...(maxVal !== undefined ? { options: { ...atMostOpts, value: maxVal } } : { options: atMostOpts }), } as FormulaNode, } - } else if (clause.relation === 'separatedFrom') { - const overlapsOpts: Record = { ...options } + } else if (decomp?.kind === 'negate' && decomp.inner) { + const innerOpts: Record = { ...options } body = { type: 'FormulaNode', kind: 'not', operand: { type: 'FormulaNode', kind: 'predicate', - predicate: 'overlaps', + predicate: decomp.inner, args: [subjectVar, referenceVar], - ...(Object.keys(overlapsOpts).length > 0 ? { options: overlapsOpts } : {}), + ...(Object.keys(innerOpts).length > 0 ? { options: innerOpts } : {}), } as FormulaNode, } } else { - const unaryPredicate = clause.relation === 'atLeast' - || clause.relation === 'atMost' - || clause.relation === 'aspectRatio' - || (clause.relation === 'inStackingContext' && !clause.reference) + const unaryPredicate = isUnaryPredicate(clause.relation) + && !(clause.relation === 'inStackingContext' && clause.reference) body = { type: 'FormulaNode', @@ -1211,7 +1216,7 @@ export function compileCanonicalClauseToFormula(clause: CanonicalClauseDescripto ...(Object.keys(options).length > 0 ? { options } : {}), } as FormulaNode - if ((clause.flags & 8) !== 0 && !unaryPredicate) { + if (hasInStackingFlag && !unaryPredicate) { body = { type: 'FormulaNode', kind: 'and', @@ -1245,11 +1250,8 @@ export function compileCanonicalClauseToFormula(clause: CanonicalClauseDescripto domain: { type: 'DomainRef' as const, domain: 'elements', selector: clause.reference }, } - const isUnary = clause.relation === 'atLeast' - || clause.relation === 'atMost' - || clause.relation === 'between' - || clause.relation === 'aspectRatio' - || (clause.relation === 'inStackingContext' && !clause.reference) + const isUnary = isUnaryPredicate(clause.relation) + && !(clause.relation === 'inStackingContext' && clause.reference) if (isUnary) { return { @@ -1613,21 +1615,6 @@ export function evaluateCardinalityAssertion( // Result Adapter: FOL → Public API shape // --------------------------------------------------------------------------- -const PREDICATE_TO_DIAGNOSTIC_CODE: Record = { - leftOf: 'IMH_RELATION_LEFT_OF_FAILED', - rightOf: 'IMH_RELATION_RIGHT_OF_FAILED', - above: 'IMH_RELATION_ABOVE_FAILED', - below: 'IMH_RELATION_BELOW_FAILED', - inside: 'IMH_RELATION_INSIDE_FAILED', - contains: 'IMH_RELATION_CONTAINS_FAILED', - alignedWith: 'IMH_ALIGNMENT_FAILED', - centeredWithin: 'IMH_RELATION_CENTERED_FAILED', - overlaps: 'IMH_RELATION_OVERLAPS_FAILED', - atLeast: 'IMH_SIZE_AT_LEAST_FAILED', - atMost: 'IMH_SIZE_AT_MOST_FAILED', - between: 'IMH_SIZE_BETWEEN_FAILED', -} - export function mapFolDiagnostic( d: { code: string; severity: 'error' | 'warning' | 'info'; category?: string; message: string; clauseId?: string }, metrics?: Record, @@ -1645,8 +1632,8 @@ export function mapFolDiagnostic( if ((code as string) === 'IMH_PREDICATE_FAILED') { const match = message.match(/Predicate "([^"]+)" failed/) const predicateName = match?.[1] - if (predicateName && PREDICATE_TO_DIAGNOSTIC_CODE[predicateName]) { - code = PREDICATE_TO_DIAGNOSTIC_CODE[predicateName] + if (predicateName && getPredicateDiagnosticCode(predicateName)) { + code = getPredicateDiagnosticCode(predicateName) as DiagnosticCode const gapMatch = message.match(/gap=([\d.-]+)/) const observedGap = gapMatch ? parseFloat(gapMatch[1]) : undefined const minGapMatch = message.match(/minGap=([\d.-]+)/)