Face Comparison: Querying different_face Results from the vuer_oss DB

Summary

A customer (“P”) asked whether failed / different_face face-comparison results are queryable in the vuer_oss Postgres DB, and how to extract them — portrait step plus liveness step — for a given date range without a new release. This triage verified the answer against the local codebase (/Users/levander/coding/facekom/vuer_oss, branch feature/FKITDEV-8747).

Core Conclusion

The customer’s premise — “the difference values are not in the DB” — is false on the write side. Face comparison results, including failing ones, are persisted to the faceComparisons table. This is a read-side query + threshold-interpretation problem, solvable today with one read-only SQL query. No release is needed.

Severity P3 — informational / data-extraction request, no production defect.

Why the Customer Thinks Failures Are Missing

Two things make failures look absent, neither of which means the data isn’t stored:

  1. different_face is not a stored status. It is a verdict computed at read time. The faceComparisons table only stores status ∈ {created, failed, success} plus a numeric euclideanDistance. A SQL query keyed off a stored “success” flag will silently miss every failing-but-status:'success' comparison (a successfully computed comparison whose distance is simply too large).
  2. A status:'success' filter exists in exactly one placeserver/web/routes/room.endpoint.js:145, the operator room view. If the customer’s prior working SQL was lifted from that endpoint’s behaviour, it filters out nothing relevant here, but it primed the expectation that “failed” rows are a distinct stored class. They are not.

Terminology — different_face is derived, not stored

different_faceCHECK_FAILURE from SelfServiceCheckerService.getFaceComparisonResult() (server/service/SelfServiceCheckerService.js:132-153): returned when euclideanDistance exceeds all configured threshold levels. To find these via SQL you must apply the threshold yourself — see the MVP query.

What Is Actually Stored

FaceComparison model

  • Model: server/db/model/faceComparison.js:18-37 → Postgres table faceComparisons.
  • Columns:
    • id
    • status — STRING enum ['created', 'failed', 'success'], default 'created'
    • euclideanDistance — FLOAT, nullable. The column comment at line 31 states it now holds cosine distance (range 0–2) despite the legacy name.
    • createdAt, updatedAt
  • FK columns (server/db/models.js:298-309): recognitionFromId, recognitionToId (→ faceRecognitions), roomId, selfServiceRoomId, customerId, userId.
  • No flowId / taskId / step column — you cannot directly tell from the row whether it came from the portrait step or the liveness step.
  • withRecognitions scope at models.js:300-305. Emits a faceComparison:updated ServiceBus event on afterSave.
  • Created by FaceRecognitionService.createFaceComparisonModel() (server/service/FaceRecognitionService.js:140-164) via compareFaceDetections() (:213-223). euclideanDistance is stored unconditionally, regardless of any threshold (:152-161) — this is the crux of why failing comparisons are queryable.

FaceRecognition model

  • Model: server/db/model/faceRecognition.js:18-30 — a per-image detection record.
  • Failed detection rows are also persisted. RecognitionService.js:234 and :362 set status = result.isResultSuccess() ? 'success' : 'failed', then call createFaceRecognitionModel unconditionally.
  • Carries imageCategory, which is the only (heuristic) lever for distinguishing portrait vs liveness — see caveats.

Face comparison persistence flow

flowchart TD
    Step["Flow step triggers<br/>face comparison"] --> Sites{"Which call site?"}
    Sites -->|"step.type liveness-check<br/>or liveness-check-v1"| NeverRow["NO row is POSSIBLE<br/>compareFaceWith is INERT<br/>(handleLivenessCheck:1199 /<br/>handleLivenessCheckV1:1459)"]
    Sites -->|"step.type liveness-check-v2<br/>SelfServiceV2Service.js:1416"| Gate{"task.options<br/>.recognitionOptions<br/>.compareFaceWith set?"}
    Sites -->|"Portrait / ID-doc<br/>FlowService.js:2918-2968"| Compare
    Sites -->|"Videochat-close hook<br/>faceRecognitionHooks.js:41"| Compare
    Gate -->|No| Skip["NO faceComparisons row<br/>(liveness step silently absent)"]
    Gate -->|Yes| Compare["compareFaceDetections()<br/>FaceRecognitionService.js:213"]
    Compare --> Create["createFaceComparisonModel()<br/>:140-160"]
    Create --> Store[("faceComparisons row<br/>euclideanDistance stored<br/>UNCONDITIONALLY<br/>status ALWAYS 'success'")]
    Store --> ReadTime["Read-time verdict:<br/>SelfServiceCheckerService<br/>.getFaceComparisonResult() :132"]
    ReadTime --> Verdict{"euclideanDistance ><br/>all thresholds?"}
    Verdict -->|Yes| Diff["different_face<br/>(CHECK_FAILURE)"]
    Verdict -->|No| Match["match / probable / perfect"]
    style Store fill:#264653,stroke:#2a9d8f,color:#fff
    style Skip fill:#3d2020,stroke:#a85,color:#fff
    style NeverRow fill:#4a1f1f,stroke:#c66,color:#fff
    style Diff fill:#3d2020,stroke:#a85,color:#fff
    style Gate fill:#2d2d2d,stroke:#888,color:#fff
    style Verdict fill:#2d2d2d,stroke:#888,color:#fff

Comparison Call Sites (4)

#StepLocationNotes
1Liveness V2server/service/SelfServiceV2Service.js:1416 gate → :1431 call, inside saveLivenessCheckV2Messages (:1349)Gated by if (task.options?.recognitionOptions?.compareFaceWith). If a client’s flow proto liveness task does not set compareFaceWith, no liveness FaceComparison row is written. The base V2 proto (self-service-v2-phase-1.flow.proto.js:78-86) does not set it. ⚠ This applies ONLY to step.type: 'liveness-check-v2' — see the correction callout below.
2Portrait / ID-documentserver/flow/FlowService.js:2943, inside handleTaskRecognitionOptions (:2899-2949)Path is server/flow/FlowService.jsnot server/service/.
3Videochat-close hookserver/faceRecognition/faceRecognitionHooks.js:41Operator flow, not the mobile branch.
4Self-service V1via the same compareFaceDetections

Liveness rows are conditional

Because of the gate at SelfServiceV2Service.js:1416, the liveness-step comparison only produces a faceComparisons row when the client’s flow proto explicitly sets compareFaceWith on the liveness task. Confirm the customer’s deployment proto before promising liveness-step data exists.

CORRECTION (2026-08-28) — compareFaceWith is inert on two of the three liveness handlers

The statement above is only true for step.type: 'liveness-check-v2'. There are three liveness handlers (server/queue/rpc_server/SelfServiceV2.js:208-219): liveness-checkhandleLivenessCheck:1199 (never persists), liveness-check-v1handleLivenessCheckV1:1459 (never persists), liveness-check-v2handleLivenessCheckV2:2114 (persists when gated open). Only the v2 handler reaches saveLivenessCheckV2Messages, so on the other two adding compareFaceWith does nothing at all. Raiffeisen/myra uses liveness-check-v1, which is why this note’s advice produced a wrong first diagnosis during SLARAFIPI-84. Full mechanism: face-comparison-persistence-paths.

Threshold Resolution

different_face depends entirely on which thresholds apply. SelfServiceCheckerService.js:122-130:

  1. Per-room first — activity-log entry selfService:v2:config:statecontent.settings.faceComparison.euclideanDistances.
  2. Fallback to globalSetting table, key faceComparison, field euclideanDistances.
  3. Code defaults (:32-48): perfect: 0.5, match: null, probable: 0.6.

So different_faceeuclideanDistance > probable, i.e. > 0.6 by default — but a given room may override this, so confirm per deployment / per room before trusting the count.

Which Endpoints Filter by Status

EndpointFilters status:'success'?
server/web/routes/room.endpoint.js:145 (operator room view)Yes
selfservice-room.endpoint.js:320No
selfserviceroom.endpoint.js:130No
flow.endpoint.js:196No
FaceComparisonService (server/service/FaceComparisonService.js:9-41)Queries FaceRecognition with status:'success'

The self-service endpoints do not filter by status. The real reason failures look “missing” is that different_face is derived, not stored — not that any endpoint hides them.

”A mobilos ág” — what the customer means

Not a git branch

“A mobilos ág” (the mobile branch / arm) = the Self-Service V2 mobile flowSelfServiceV2Service plus the customization/flow/self-service-v2-phase-1/ protos. It is not a git or customization branch.

MVP Deliverable — Read-Only SQL

This is the answer to the customer’s request. Read-only, no release. Adjust different_face_threshold to the deployment’s actual euclideanDistances.probable and set the date range:

WITH params AS (
  SELECT
    0.6::float8                        AS different_face_threshold,  -- euclideanDistances.probable
    '2026-04-01 00:00:00'::timestamptz AS range_start,
    '2026-05-01 00:00:00'::timestamptz AS range_end
)
SELECT
    fc.id AS face_comparison_id, fc."createdAt" AS compared_at, fc.status AS comparison_status,
    fc."euclideanDistance" AS difference_value, fc."selfServiceRoomId", fc."roomId",
    fc."customerId", fc."userId",
    rf."imageCategory" AS from_image_category, rt."imageCategory" AS to_image_category,
    rf.id AS recognition_from_id, rt.id AS recognition_to_id
FROM "faceComparisons" fc
JOIN params p ON TRUE
LEFT JOIN "faceRecognitions" rf ON rf.id = fc."recognitionFromId"
LEFT JOIN "faceRecognitions" rt ON rt.id = fc."recognitionToId"
WHERE fc."createdAt" >= p.range_start AND fc."createdAt" < p.range_end
  AND fc.status = 'success' AND fc."euclideanDistance" IS NOT NULL
  AND fc."euclideanDistance" > p.different_face_threshold
ORDER BY fc."createdAt" DESC;

status = 'success' here means the comparison was computed successfully — these are exactly the rows where a different_face verdict is meaningful (a large but valid distance).

CORRECTION (2026-08-28) — there are no status = 'failed' rows

This note previously said “status = 'failed' rows represent comparisons that could not be computed at all”. No such rows exist. createFaceComparisonModel (FaceRecognitionService.js:140-160) defaults status = 'success', and of the three call sites one passes 'success' explicitly while the other two pass no status. Nothing in vuer_oss ever writes status:'failed' to faceComparisons. Every upstream guard failure produces no row at all. The status predicate in the SQL above is therefore a no-op — harmless, but it must not be read as “excluding the failures”. See face-comparison-persistence-paths.

Caveats / Limitations

Record these caveats when handing the SQL to the customer

  1. Liveness-step rows are conditional — and often impossible. For step.type: 'liveness-check-v2' they exist only if the flow proto sets compareFaceWith (gate at SelfServiceV2Service.js:1416); the base V2 proto does not. For step.type: 'liveness-check' or liveness-check-v1 (what Raiffeisen/myra uses) no liveness row can ever exist, and compareFaceWith is inert. See face-comparison-persistence-paths.
  2. Threshold is overridable — per-room or global config can change probable away from the 0.6 default. Confirm the real value before trusting the result count.
  3. No step columnfaceComparisons has no portrait-vs-liveness discriminator. The only signal is the joined FaceRecognition.imageCategory: ID-document categories → portrait step; customer-portrait → liveness step. imageCategory values are themselves flow-config-dependent, so treat this as a heuristic, not a guarantee.
  4. euclideanDistance holds cosine distance (range 0–2) despite the column name. The threshold is interpreted in the same space.

Corrections to Prior Reference Docs

Stale facts in the FaceKom triage agents' _shared-context.md

  • It states the model import hub is server/db/models.ts — it is actually server/db/models.js (plain JS).
  • FlowService.js is under server/flow/, not server/service/. These should be corrected in _shared-context.md so future triage doesn’t chase the wrong paths.

The Customer’s “Previous Ticket”

The customer referenced a prior ticket containing working SQL for successful comparisons. That ticket was not found in any local store — it is likely YouTrack-only. The support engineer should pull it from YouTrack; it would confirm exactly which schema/columns the customer is already comfortable with.

  • face-comparison-persistence-pathsread this first; supersedes this note’s liveness-gating and status:'failed' claims (corrections dated 2026-08-28)
  • SLARAFIPI-84 — the ticket where this note’s stale advice produced a wrong first diagnosis
  • vuer_oss — host service; faceComparisons / faceRecognitions tables, FaceRecognitionService, SelfServiceV2Service, FlowService
  • vuer_cv — computer-vision service producing the recognition results compared here
  • database-schema — Postgres schema reference
  • FaceKom — platform overview
  • Related tickets: FKITDEV-8752 (KH face-match logic rework, PR #7914), FKITDEV-6437 (face comparison limit perfect→probable), FKITDEV-7166 (face compare mods), FKITDEV-8484 (unicredit liveness v2), FKITDEV-8145 (liveness check v2)