Source code for pain001.faces.models
# Copyright (C) 2023-2026 Pain001. All rights reserved.
# SPDX-License-Identifier: Apache-2.0 OR MIT
#
# Licensed under either of the Apache License, Version 2.0 or the MIT
# License, at your option. You may not use this file except in
# compliance with one of those licences. Copies are provided in
# LICENSE-APACHE and LICENSE-MIT.
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the Licences is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied. See the applicable Licence for the specific language
# governing permissions and limitations.
"""Data models and results for face projections (ADR-0005)."""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
[docs]
class FaceError(ValueError):
"""Raised when a face projection fails or an unsupported face is requested."""
[docs]
@dataclass(frozen=True)
class LossReport:
"""Detailed report of mapped vs unmapped element paths in a projection.
Attributes:
face: The name of the projection standard.
total_source_elements: Total number of leaf element paths in the source.
mapped_paths: Element paths whose values were carried into the projection.
unmapped_paths: Element paths present in the source that have no counterpart.
"""
face: str
total_source_elements: int
mapped_paths: tuple[str, ...]
unmapped_paths: tuple[str, ...]
@property
def is_lossless(self) -> bool:
"""True when every source element path was carried into the target projection."""
return len(self.unmapped_paths) == 0
@property
def loss_ratio(self) -> float:
"""Proportion of source elements not mapped into the target projection (0.0 to 1.0)."""
if self.total_source_elements == 0:
return 0.0
return len(self.unmapped_paths) / self.total_source_elements
[docs]
def to_dict(self) -> dict[str, Any]:
"""Convert the loss report into a JSON-serializable dictionary."""
return {
"face": self.face,
"total_source_elements": self.total_source_elements,
"mapped_count": len(self.mapped_paths),
"unmapped_count": len(self.unmapped_paths),
"is_lossless": self.is_lossless,
"loss_ratio": round(self.loss_ratio, 4),
"mapped_paths": list(self.mapped_paths),
"unmapped_paths": list(self.unmapped_paths),
}
[docs]
@dataclass(frozen=True)
class FaceResult:
"""The result of projecting a pain.001 document into a target face.
Attributes:
face: The name of the target standard (e.g. ``uk_open_banking``).
version: The pain.001 source edition (e.g. ``pain.001.001.09``).
payload: The JSON-serializable dictionary conforming to the target standard.
loss_report: Details of which elements were mapped vs omitted.
"""
face: str
version: str
payload: dict[str, Any]
loss_report: LossReport
[docs]
def to_dict(self) -> dict[str, Any]:
"""Return a complete dictionary containing payload and loss metadata."""
return {
"face": self.face,
"version": self.version,
"payload": self.payload,
"loss_report": self.loss_report.to_dict(),
}
[docs]
def to_json(self, indent: int = 2) -> str:
"""Serialize the projection payload to formatted JSON."""
return json.dumps(self.payload, indent=indent)