> For the complete documentation index, see [llms.txt](https://docs.openg2p.org/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.openg2p.org/products/registry/registry/design/score-computation-framework.md).

# Score Computation framework

## Score Computation — Feature Design Document

**Project:** OpenG2P Registry Gen2\
**Feature:** Score Computation\
**Status:** Design / Pre-implementation\
**Date:** 2026-04-09

***

### 1. Overview

Score Computation is an asynchronous, extensible feature that automatically computes domain-specific scores (e.g. PMT Score, FSF Score) for records in a registry, whenever a change request that touches one or more contributing attributes is approved.

The design follows the same **metadata → trigger → queue → beat/worker → interface/factory → results** pattern used by deduplication and functional ID generation in the existing codebase. Core provides all infrastructure; domain extensions provide only the computation logic.

***

### 2. Scope and Constraints

* Score computation is available **only for registers with `register_purpose = RegisterPurposeEnum.REGISTER`**. Registers with purpose `PROGRAM_REGISTER` or `TABLE` are excluded.
* Every insert or update to a domain register table is **always mediated through a Change Request** — there are no direct writes. Score computation therefore triggers on **Change Request approval**, not on CR creation.
* The feature is **fully asynchronous**: the approval API returns immediately; the score is computed in the background by a Celery worker.
* The base registry (`openg2p-registry-gen2-core` and `openg2p-registry-gen2-celery`) provides all infrastructure. The **actual computation formula** lives entirely in `openg2p-registry-gen2-extensions`.

***

### 3. Design Across Repositories

| Repository                         | What changes                                              |
| ---------------------------------- | --------------------------------------------------------- |
| `openg2p-registry-gen2-core`       | New models, interface, factory, controller service        |
| `openg2p-registry-gen2-celery`     | New beat producer, worker, Workers constant, config keys  |
| `openg2p-registry-gen2-apis`       | New API endpoint on staff portal                          |
| `openg2p-registry-gen2-ui-widgets` | New `scores-display` widget                               |
| `openg2p-registry-gen2-extensions` | Concrete compute service implementations (per score type) |

***

### 4. Data Model

#### 4.1 `G2PRegisterScoreDefinition`

Stores the score types configured for a register, and which attributes contribute to each score. One register may have multiple score definitions (one per score type).

**Location:** `openg2p-registry-gen2-core/openg2p-registry-core/src/openg2p_registry_core/models/g2p_register_score_definition.py`

```python
class G2PRegisterScoreDefinition(BaseORMModel):
    __tablename__ = "g2p_register_score_definitions"

    score_definition_id: Mapped[str] = mapped_column(
        String, primary_key=True, default=lambda: str(uuid.uuid4())
    )
    register_id: Mapped[str] = mapped_column(
        String, nullable=False, index=True
    )
    # e.g. "PMT_SCORE", "FSF_SCORE" — used as factory lookup key
    score_type: Mapped[str] = mapped_column(String, nullable=False, index=True)

    # JSON list of field paths that contribute to this score
    # e.g. ["income", "assets.land_area", "household.member_count"]
    contributing_attributes: Mapped[JSON] = mapped_column(JSON, nullable=False)

    # Optional extra config for the compute implementation
    # e.g. {"formula_version": "2024", "external_api_url": "..."}
    score_config: Mapped[JSON] = mapped_column(JSON, nullable=True)

    is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
```

**Constraint enforced at service layer:** `register_purpose` must be `RegisterPurposeEnum.REGISTER` when creating or updating a score definition.

**Unique constraint:** `(register_id, score_type)` — a register may only have one definition per score type.

***

#### 4.2 `G2PScoreComputeQueue`

One queue item per `(internal_record_id, score_type)` per trigger event. Follows the same pattern as `G2PFunctionalIdGenerationQueue`.

**Location:** `openg2p-registry-gen2-core/openg2p-registry-core/src/openg2p_registry_core/models/g2p_score_compute_queue.py`

```python
class G2PScoreComputeQueue(BaseORMModel):
    __tablename__ = "g2p_score_compute_queue"

    queue_id: Mapped[str] = mapped_column(
        String, primary_key=True, default=lambda: str(uuid.uuid4())
    )
    register_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
    internal_record_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
    score_definition_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
    score_type: Mapped[str] = mapped_column(String, nullable=False, index=True)

    # FK to the change request that triggered this computation
    change_request_id: Mapped[str] = mapped_column(String, nullable=False, index=True)

    # Snapshot of contributing attribute values at time of CR approval
    # Eliminates need for the worker to re-read the domain register table
    contributing_attribute_values: Mapped[JSON] = mapped_column(JSON, nullable=False)

    # Processing status — reuses ProcessStatusEnum from existing codebase
    compute_status: Mapped[ProcessStatusEnum] = mapped_column(
        String, nullable=False, default=ProcessStatusEnum.PENDING, index=True
    )
    compute_no_of_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
    compute_latest_timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=True)
    compute_latest_error_code: Mapped[str] = mapped_column(String, nullable=True)
```

**Upsert behaviour:** When a trigger event fires for a `(internal_record_id, score_type)` pair that already has a `PENDING` queue item, the existing row is updated (`change_request_id` and `contributing_attribute_values` are refreshed) rather than inserting a duplicate. This ensures the worker always computes on the most recent approved state.

***

#### 4.3 `G2PRegisterScore`

Stores the latest computed score per record per score type. One row per `(internal_record_id, score_type)`, upserted on every computation.

**Location:** `openg2p-registry-gen2-core/openg2p-registry-core/src/openg2p_registry_core/models/g2p_register_score.py`

```python
class G2PRegisterScore(BaseORMModel):
    __tablename__ = "g2p_register_scores"

    score_id: Mapped[str] = mapped_column(
        String, primary_key=True, default=lambda: str(uuid.uuid4())
    )
    register_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
    internal_record_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
    score_type: Mapped[str] = mapped_column(String, nullable=False, index=True)
    score_definition_id: Mapped[str] = mapped_column(String, nullable=False, index=True)

    # The CR that last triggered this computation — full audit trail
    triggered_by_cr_id: Mapped[str] = mapped_column(String, nullable=False, index=True)

    computed_score: Mapped[float] = mapped_column(Float, nullable=False)
    computed_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
```

**Upsert key:** `(internal_record_id, score_type)`. The `score_id` remains stable across recomputations; only `computed_score`, `computed_at`, and `triggered_by_cr_id` are updated.

***

#### 4.4 `G2PRegisterScoreHistory` *(recommended)*

Appends an immutable snapshot on every computation. Consistent with the pattern used by `G2PRegisterHistory` in the existing codebase.

```python
class G2PRegisterScoreHistory(BaseORMModel):
    __tablename__ = "g2p_register_score_history"

    history_id: Mapped[str] = mapped_column(
        String, primary_key=True, default=lambda: str(uuid.uuid4())
    )
    register_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
    internal_record_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
    score_type: Mapped[str] = mapped_column(String, nullable=False, index=True)
    score_definition_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
    triggered_by_cr_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
    computed_score: Mapped[float] = mapped_column(Float, nullable=False)
    computed_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
```

This table is append-only. It enables answering "how has this record's PMT score changed over time and which CR caused each change?"

***

### 5. Trigger: Change Request Approval

The queue is populated inside `G2PRegisterService.approve_change_request()`, after the domain record has been updated and the history snapshot written. This is the only correct trigger point — the contributing attribute values in the domain table are not updated until approval.

#### Trigger Logic

```python
# Inside G2PRegisterService.approve_change_request(change_request_id, ...)
# ... existing approval logic (update domain table, write history) ...

# [NEW] Score compute queue population
await self._enqueue_score_computations(
    session=session,
    register_id=change_request.register_id,
    internal_record_id=change_request.internal_record_id,
    change_request_id=change_request.change_request_id,
    approved_payload=change_request_payload.change_payload,
)
```

#### `_enqueue_score_computations` logic

```
1. Fetch all G2PRegisterScoreDefinition records for register_id WHERE is_enabled = True
2. Check register_purpose = REGISTER (guard — skip silently if not)
3. For each score_definition:
   a. Extract the keys present in approved_payload
   b. Check if any key intersects with score_definition.contributing_attributes
   c. If no intersection → skip (this CR did not touch any contributing attribute)
   d. If intersection:
      i.  Read current values of ALL contributing_attributes from the domain record
          (not just the changed ones — the score needs the full attribute set)
      ii. UPSERT into g2p_score_compute_queue:
            - If PENDING row exists for (internal_record_id, score_type):
                update change_request_id, contributing_attribute_values, updated_at
            - Else: insert new row with status = PENDING
```

***

### 6. Interface and Factory

#### 6.1 `G2PScoreComputeInterface`

**Location:** `openg2p-registry-gen2-core/.../interfaces/g2p_score_compute_interface.py`

```python
from abc import ABC, abstractmethod
from uuid import UUID

class G2PScoreComputeInterface(ABC):

    @abstractmethod
    async def compute_score(
        self,
        internal_record_id: str,
        contributing_attribute_values: dict,
        score_config: dict,
    ) -> float:
        """
        Compute and return the score for the given record.

        Args:
            internal_record_id:            The UUID of the register record.
            contributing_attribute_values: Snapshot of the attribute values
                                           that feed into this score (from queue).
            score_config:                  Implementation-specific configuration
                                           from G2PRegisterScoreDefinition.score_config.
        Returns:
            The computed score as a float.
        """
        ...
```

#### 6.2 `G2PScoreComputeFactory`

**Location:** `openg2p-registry-gen2-core/.../interfaces/g2p_score_compute_factory.py`

Mirrors `G2PPayloadEnricherFactory` exactly. Uses `importlib` to load the implementation from the extensions namespace at runtime.

```python
import importlib
from openg2p_fastapi_common.service import BaseService
from .g2p_score_compute_interface import G2PScoreComputeInterface

class G2PScoreComputeFactory(BaseService):

    def get_compute_service(self, score_type: str) -> G2PScoreComputeInterface:
        """
        Dynamically loads the compute service for the given score_type.

        Naming convention:
            score_type "PMT_SCORE"  →  G2PScoreComputeServicePmtScore
            score_type "FSF_SCORE"  →  G2PScoreComputeServiceFsfScore

        The implementation class must exist in:
            openg2p_registry_extensions.score_compute.services
        """
        class_name = self._score_type_to_class_name(score_type)
        module = importlib.import_module(
            "openg2p_registry_extensions.score_compute.services"
        )
        compute_class = getattr(module, class_name)
        return compute_class()

    @staticmethod
    def _score_type_to_class_name(score_type: str) -> str:
        # "PMT_SCORE" → "G2PScoreComputeServicePmtScore"
        title_case = score_type.replace("_", " ").title().replace(" ", "")
        return f"G2PScoreComputeService{title_case}"
```

***

### 7. Celery Beat Producer and Worker

#### 7.1 Beat Producer

**Location:** `openg2p-registry-gen2-celery/.../tasks/score_compute_beat_producer.py`

Follows the exact pattern of `deduplication_register_beat_producer.py`.

```python
@celery_app.task(name="score_compute_beat_producer")
def score_compute_beat_producer():
    """
    Queries PENDING score compute queue items and dispatches them to the worker.
    Transitions status from PENDING → PROCESSING before dispatch.
    """
    with session_maker() as session:
        pending_items = (
            session.execute(
                select(G2PScoreComputeQueue)
                .filter(G2PScoreComputeQueue.compute_status == ProcessStatusEnum.PENDING)
                .limit(_config.no_of_tasks_to_process)
            )
            .scalars().all()
        )

        for item in pending_items:
            item.compute_status = ProcessStatusEnum.PROCESSING
            session.add(item)
            celery_app.send_task(
                Workers.SCORE_COMPUTE_WORKER,
                args=(item.queue_id,),
                queue=_config.worker_queue,
            )
        session.commit()
```

#### 7.2 Worker

**Location:** `openg2p-registry-gen2-celery/.../tasks/score_compute_worker.py`

The worker is stateless — it reads everything it needs from the queue item (including the attribute snapshot and the `change_request_id`). The worker delegates to the factory; it never contains formula logic.

```python
@celery_app.task(name="score_compute_worker", bind=True, max_retries=3)
def score_compute_worker(self, queue_id: str):
    with session_maker() as session:
        queue_item = session.get(G2PScoreComputeQueue, queue_id)
        score_definition = session.get(G2PRegisterScoreDefinition, queue_item.score_definition_id)

        try:
            # 1. Load factory and get domain-specific compute service
            factory = G2PScoreComputeFactory.get_component()
            compute_service = factory.get_compute_service(queue_item.score_type)

            # 2. Compute the score
            computed_score = asyncio.get_event_loop().run_until_complete(
                compute_service.compute_score(
                    internal_record_id=queue_item.internal_record_id,
                    contributing_attribute_values=queue_item.contributing_attribute_values,
                    score_config=score_definition.score_config or {},
                )
            )

            # 3. Upsert into g2p_register_scores
            _upsert_score(session, queue_item, computed_score)

            # 4. Append to score history
            _append_score_history(session, queue_item, computed_score)

            # 5. Mark queue item COMPLETED
            queue_item.compute_status = ProcessStatusEnum.COMPLETED
            queue_item.compute_latest_timestamp = datetime.utcnow()
            session.commit()

        except Exception as e:
            session.rollback()
            queue_item.compute_no_of_attempts += 1
            queue_item.compute_latest_error_code = str(e)
            queue_item.compute_latest_timestamp = datetime.utcnow()

            if self.request.retries < self.max_retries:
                queue_item.compute_status = ProcessStatusEnum.PENDING   # reset for retry
            else:
                queue_item.compute_status = ProcessStatusEnum.FAILED

            session.add(queue_item)
            session.commit()
            raise
```

#### 7.3 `Workers` constants update

Add to `openg2p-registry-gen2-celery/.../utils/workers.py`:

```python
SCORE_COMPUTE_WORKER = "score_compute_worker"
```

#### 7.4 Beat schedule and config

Add to `openg2p-registry-celery-beat-producers/src/.../app.py` beat schedule:

```python
"score_compute_beat_producer": {
    "task": "score_compute_beat_producer",
    "schedule": _config.score_compute_beat_producer_frequency
               or _config.default_beat_producer_frequency,
},
```

Add to `config.py`:

```python
score_compute_beat_producer_frequency: Optional[int] = None
```

***

### 8. Core Service

**Location:** `openg2p-registry-gen2-core/.../services/g2p_score_compute_service.py`

Provides the `_enqueue_score_computations` method (called from `G2PRegisterService.approve_change_request`) and manages the score definition CRUD. This is a `BaseService` singleton.

```python
class G2PScoreComputeService(BaseService):

    async def get_score_definitions(self, register_id: str, session) -> list[G2PRegisterScoreDefinition]:
        ...

    async def create_score_definition(self, register_id: str, score_type: str,
                                       contributing_attributes: list, score_config: dict,
                                       session) -> G2PRegisterScoreDefinition:
        # Guard: register_purpose must be REGISTER
        ...

    async def enqueue_score_computations(self, register_id: str, internal_record_id: str,
                                          change_request_id: str, approved_payload: dict,
                                          session) -> None:
        # Core trigger logic described in Section 5
        ...

    async def get_scores_for_record(self, internal_record_id: str,
                                     session) -> list[G2PRegisterScore]:
        ...

    async def get_score_history(self, internal_record_id: str, score_type: str,
                                 session) -> list[G2PRegisterScoreHistory]:
        ...
```

***

### 9. Controller Service and API Endpoint

#### 9.1 Controller Service

**Location:** `openg2p-registry-gen2-core/.../controller_services/g2p_score_controller_service.py`

```python
class G2PScoreControllerService(BaseService):

    async def get_scores_for_record(self, request: GetScoresRequest) -> GetScoresResponse:
        # Calls G2PScoreComputeService.get_scores_for_record()
        # Returns list of { score_type, computed_score, computed_at, triggered_by_cr_id }
        ...

    async def get_score_history(self, request: GetScoreHistoryRequest) -> GetScoreHistoryResponse:
        ...
```

#### 9.2 Staff Portal API Controller

**Location:** `openg2p-registry-gen2-apis/openg2p-registry-staff-portal-api/.../controllers/g2p_score_controller.py`

```
POST /register-data/get_scores
    Request:  { internal_record_id: str }
    Response: { scores: [{ score_type, computed_score, computed_at, triggered_by_cr_id }] }
    Auth:     @require_permissions("registerScore:view")

POST /register-data/get_score_history
    Request:  { internal_record_id: str, score_type: str }
    Response: { history: [{ computed_score, computed_at, triggered_by_cr_id }] }
    Auth:     @require_permissions("registerScore:view")

POST /register-metadata/get_score_definitions
    Request:  { register_id: str }
    Response: { score_definitions: [...] }
    Auth:     @require_permissions("registerDefinition:view")

POST /register-metadata/create_score_definition
    Request:  { register_id, score_type, contributing_attributes, score_config }
    Response: { score_definition: {...} }
    Auth:     @require_permissions("registerDefinition:manage")

POST /register-metadata/update_score_definition
    Request:  { score_definition_id, contributing_attributes?, score_config?, is_enabled? }
    Response: { score_definition: {...} }
    Auth:     @require_permissions("registerDefinition:manage")
```

***

### 10. Extension Implementation

#### Directory structure in `openg2p-registry-gen2-extensions`

```
openg2p-registry-{domain}-extension/
└── src/
    └── openg2p_registry_extensions/        ← namespace mapping
        └── score_compute/
            └── services/
                ├── __init__.py
                ├── g2p_score_compute_service_pmt_score.py
                └── g2p_score_compute_service_fsf_score.py
```

#### Example PMT Score implementation

```python
# g2p_score_compute_service_pmt_score.py
from openg2p_registry_core.interfaces import G2PScoreComputeInterface

class G2PScoreComputeServicePmtScore(G2PScoreComputeInterface):

    async def compute_score(
        self,
        internal_record_id: str,
        contributing_attribute_values: dict,
        score_config: dict,
    ) -> float:
        """
        Proxy Means Test (PMT) score computation.
        Weights and formula version are read from score_config.
        """
        weights = score_config.get("weights", {})
        score = 0.0

        income       = contributing_attribute_values.get("income", 0)
        land_area    = contributing_attribute_values.get("assets.land_area", 0)
        member_count = contributing_attribute_values.get("household.member_count", 1)

        score += income       * weights.get("income", 0.4)
        score += land_area    * weights.get("land_area", 0.3)
        score += member_count * weights.get("member_count", 0.3)

        return round(score, 4)
```

#### Namespace packaging (`pyproject.toml`)

```toml
[tool.hatch.build.targets.wheel.sources]
"src/openg2p_registry_extensions" = "openg2p_registry_extensions"
```

This ensures the factory's `importlib.import_module("openg2p_registry_extensions.score_compute.services")` resolves correctly when the extension package is installed alongside core.

***

### 11. UI — Scores Display

#### 11.1 New widget: `scores-display`

**Location:** `openg2p-registry-gen2-ui-widgets/src/widgets/ScoresDisplayWidget.tsx`

A read-only widget that fetches and displays all computed scores for the current record.

```tsx
interface ScoresDisplayConfig extends BaseWidgetConfig {
  widget: 'scores-display';
  'widget-data-source': {
    type: 'api';
    service: string;        // e.g. "staff-portal-api"
    endpoint: string;       // e.g. "get_scores"
    params: { internal_record_id_path: string };  // dot-path to record ID in form values
  };
}
```

Renders as a compact table:

| Score Type | Score | Computed At      | Triggered by CR |
| ---------- | ----- | ---------------- | --------------- |
| PMT\_SCORE | 47.32 | 2026-04-08 14:22 | CR-00123        |
| FSF\_SCORE | 82.10 | 2026-04-08 14:22 | CR-00123        |

Shows a spinner/pending badge when a `PENDING` queue item exists for the record (polled via a secondary data source or SSE).

Register the widget in `src/registry/defaultWidgets.ts`:

```typescript
import ScoresDisplayWidget from '../widgets/ScoresDisplayWidget';
widgetRegistry.register('scores-display', ScoresDisplayWidget);
```

#### 11.2 Core Section in UI Schema

Scores are surfaced as a **core section** — a section managed by the registry platform, not user-configurable via `SectionBuilder`. Distinguish it with a `section-is-core: true` flag in `SectionConfig`:

```json
{
  "section-id": "scores",
  "section-title": "Computed Scores",
  "section-is-core": true,
  "section-editable": false,
  "panels": [
    {
      "panel-id": "scores-panel",
      "widgets": [
        {
          "widget": "scores-display",
          "widget-id": "record-scores",
          "widget-data-source": {
            "type": "api",
            "service": "staff-portal-api",
            "endpoint": "get_scores",
            "params": { "internal_record_id_path": "internal_record_id" }
          }
        }
      ]
    }
  ]
}
```

`SectionBuilder` should hide or lock sections with `section-is-core: true` to prevent accidental editing.

***

### 12. End-to-End Data Flow

```
Staff approves Change Request (change_request_id = "CR-00123")
  │
  ▼
G2PRegisterService.approve_change_request("CR-00123")
  ├─ Updates G2PRegisterFarmer (income: 5000 → 3500)
  ├─ Writes G2PRegisterHistoryFarmer snapshot
  └─ Calls G2PScoreComputeService.enqueue_score_computations()
       │
       ├─ Loads score definitions for register_id
       │    [PMT_SCORE: contributing = ["income", "assets.land_area"]]
       │    [FSF_SCORE: contributing = ["income", "household.member_count"]]
       │
       ├─ "income" ∈ approved_payload → both score types affected
       │
       ├─ Reads current values: { income: 3500, assets.land_area: 2.1,
       │                           household.member_count: 4 }
       │
       ├─ UPSERT g2p_score_compute_queue:
       │    Row 1: (record_A, PMT_SCORE, CR-00123, {income:3500, land:2.1}, PENDING)
       │    Row 2: (record_A, FSF_SCORE, CR-00123, {income:3500, members:4}, PENDING)
       └─ Returns (API response already sent)

    ↓  (every ~20 seconds)

score_compute_beat_producer
  ├─ Queries PENDING rows → finds Row 1 and Row 2
  ├─ Sets both to PROCESSING
  └─ celery_app.send_task("score_compute_worker", args=("queue_id_1",))
     celery_app.send_task("score_compute_worker", args=("queue_id_2",))

    ↓

score_compute_worker("queue_id_1")  [PMT_SCORE]
  ├─ Reads queue item → score_type="PMT_SCORE", cr_id="CR-00123"
  ├─ factory.get_compute_service("PMT_SCORE")
  │    → loads G2PScoreComputeServicePmtScore from extensions
  ├─ compute_score({income:3500, land:2.1}, score_config) → 47.32
  ├─ UPSERT g2p_register_scores:
  │    (record_A, PMT_SCORE, 47.32, now(), triggered_by="CR-00123")
  ├─ INSERT g2p_register_score_history:
  │    (record_A, PMT_SCORE, 47.32, now(), triggered_by="CR-00123")
  └─ Sets queue item → COMPLETED

    ↓

Staff portal UI loads the record
  └─ ScoresDisplayWidget calls GET /register-data/get_scores
       → returns [{ PMT_SCORE: 47.32 }, { FSF_SCORE: 82.10 }]
```

***

### 13. Migration Checklist

All new tables require Alembic migrations. Add to `CoreInitializer.migrate_database()`:

```python
await G2PRegisterScoreDefinition.create_migrate()
await G2PScoreComputeQueue.create_migrate()
await G2PRegisterScore.create_migrate()
await G2PRegisterScoreHistory.create_migrate()
```

***

### 14. Summary of All New Artifacts

#### `openg2p-registry-gen2-core`

| Type      | File                                                  | Description                    |
| --------- | ----------------------------------------------------- | ------------------------------ |
| Model     | `models/g2p_register_score_definition.py`             | Score type config per register |
| Model     | `models/g2p_score_compute_queue.py`                   | Async compute queue            |
| Model     | `models/g2p_register_score.py`                        | Latest score per record/type   |
| Model     | `models/g2p_register_score_history.py`                | Immutable score audit trail    |
| Interface | `interfaces/g2p_score_compute_interface.py`           | Abstract `compute_score()`     |
| Factory   | `interfaces/g2p_score_compute_factory.py`             | Dynamic loader by `score_type` |
| Service   | `services/g2p_score_compute_service.py`               | Enqueue logic, CRUD, results   |
| Ctrl Svc  | `controller_services/g2p_score_controller_service.py` | HTTP layer                     |

#### `openg2p-registry-gen2-celery`

| Type     | File                                   | Description                             |
| -------- | -------------------------------------- | --------------------------------------- |
| Beat     | `tasks/score_compute_beat_producer.py` | Polls PENDING, dispatches               |
| Worker   | `tasks/score_compute_worker.py`        | Computes, persists, retries             |
| Constant | `utils/workers.py`                     | `SCORE_COMPUTE_WORKER`                  |
| Config   | `config.py`                            | `score_compute_beat_producer_frequency` |

#### `openg2p-registry-gen2-apis`

| Type       | File                      | Description     |
| ---------- | ------------------------- | --------------- |
| Controller | `g2p_score_controller.py` | 5 new endpoints |

#### `openg2p-registry-gen2-ui-widgets`

| Type     | File                              | Description                    |
| -------- | --------------------------------- | ------------------------------ |
| Widget   | `widgets/ScoresDisplayWidget.tsx` | `scores-display` widget        |
| Types    | `types/index.ts`                  | `ScoresDisplayConfig` type     |
| Registry | `registry/defaultWidgets.ts`      | Auto-register `scores-display` |

#### `openg2p-registry-gen2-extensions`

| Type | File                                                            | Description |
| ---- | --------------------------------------------------------------- | ----------- |
| Impl | `score_compute/services/g2p_score_compute_service_pmt_score.py` | PMT formula |
| Impl | `score_compute/services/g2p_score_compute_service_fsf_score.py` | FSF formula |


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.openg2p.org/products/registry/registry/design/score-computation-framework.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
