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 -->|"Liveness V2<br/>SelfServiceV2Service.js:1390"| Gate{"task.options<br/>.recognitionOptions<br/>.compareFaceWith set?"}
    Sites -->|"Portrait / ID-doc<br/>FlowService.js:2943"| Compare
    Sites -->|"Videochat-close hook<br/>faceRecognitionHooks.js:41"| Compare
    Sites -->|"Self-service V1"| 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-164"]
    Create --> Store[("faceComparisons row<br/>status + euclideanDistance<br/>stored UNCONDITIONALLY")]
    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 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:1390, inside saveLivenessCheckV2Messages (:1308-1412)Gated at :1375 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.
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:1375, 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.

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). status = 'failed' rows represent comparisons that could not be computed at all and are not different_face cases.

Caveats / Limitations

Record these caveats when handing the SQL to the customer

  1. Liveness-step rows are conditional — they exist only if the client flow proto sets compareFaceWith on the liveness task (gate at SelfServiceV2Service.js:1375). The base V2 proto does not.
  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.

  • 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)