OpenAPI 3.0 Spec (v0.1)

openapi: 3.0.3

info:

  title: KILO Photography Platform API

  version: “0.1.0”

  description: |

    KILO is an AI-first photography platform for ingest, culling, editing, search, and client delivery.

    This API is designed for web + desktop clients with resumable ingest and async processing jobs.

servers:

  – url: https://api.kilo.photo/v1

tags:

  – name: Auth

  – name: Projects

  – name: Assets

  – name: Clusters

  – name: Culling

  – name: Edits

  – name: Search

  – name: Galleries

  – name: Exports

  – name: Jobs

  – name: Realtime

security:

  – bearerAuth: []

paths:

  /auth/login:

    post:

      tags: [Auth]

      summary: Start passwordless login (magic link / code)

      description: Creates a login challenge and sends a verification code to the email.

      security: []

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/LoginStartRequest”

      responses:

        “200”:

          description: Challenge created

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/LoginStartResponse”

        “400”:

          $ref: “#/components/responses/BadRequest”

  /auth/verify:

    post:

      tags: [Auth]

      summary: Verify login challenge and mint tokens

      security: []

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/LoginVerifyRequest”

      responses:

        “200”:

          description: Tokens minted

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/LoginVerifyResponse”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

  /projects:

    get:

      tags: [Projects]

      summary: List projects

      parameters:

        – $ref: “#/components/parameters/Cursor”

        – $ref: “#/components/parameters/Limit”

      responses:

        “200”:

          description: Projects page

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/PagedProjects”

        “401”:

          $ref: “#/components/responses/Unauthorized”

    post:

      tags: [Projects]

      summary: Create project

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/CreateProjectRequest”

      responses:

        “201”:

          description: Project created

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Project”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

  /projects/{projectId}:

    get:

      tags: [Projects]

      summary: Get project

      parameters:

        – $ref: “#/components/parameters/ProjectId”

      responses:

        “200”:

          description: Project

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Project”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

    patch:

      tags: [Projects]

      summary: Update project

      parameters:

        – $ref: “#/components/parameters/ProjectId”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/UpdateProjectRequest”

      responses:

        “200”:

          description: Project updated

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Project”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /projects/{projectId}/archive:

    post:

      tags: [Projects]

      summary: Archive project

      parameters:

        – $ref: “#/components/parameters/ProjectId”

      responses:

        “200”:

          description: Archived

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Project”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /projects/{projectId}/assets:prepareUpload:

    post:

      tags: [Assets]

      summary: Prepare signed URLs for upload

      description: |

        Returns signed upload URLs for direct-to-object-storage upload.

        Use Idempotency-Key to safely retry.

      parameters:

        – $ref: “#/components/parameters/ProjectId”

        – $ref: “#/components/parameters/IdempotencyKey”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/PrepareUploadRequest”

      responses:

        “200”:

          description: Signed upload URLs

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/PrepareUploadResponse”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “409”:

          $ref: “#/components/responses/Conflict”

  /projects/{projectId}/assets:finalizeUpload:

    post:

      tags: [Assets]

      summary: Finalize uploaded assets and enqueue processing

      parameters:

        – $ref: “#/components/parameters/ProjectId”

        – $ref: “#/components/parameters/IdempotencyKey”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/FinalizeUploadRequest”

      responses:

        “200”:

          description: Assets finalized and jobs queued

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/FinalizeUploadResponse”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “409”:

          $ref: “#/components/responses/Conflict”

  /projects/{projectId}/assets:

    get:

      tags: [Assets]

      summary: List assets in a project

      parameters:

        – $ref: “#/components/parameters/ProjectId”

        – $ref: “#/components/parameters/Cursor”

        – $ref: “#/components/parameters/Limit”

        – name: picked

          in: query

          schema: { type: boolean }

        – name: rejected

          in: query

          schema: { type: boolean }

        – name: ratingMin

          in: query

          schema: { type: integer, minimum: 1, maximum: 5 }

      responses:

        “200”:

          description: Assets page

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/PagedAssets”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /assets/{assetId}:

    get:

      tags: [Assets]

      summary: Get asset

      parameters:

        – $ref: “#/components/parameters/AssetId”

      responses:

        “200”:

          description: Asset

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Asset”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

    patch:

      tags: [Assets]

      summary: Update asset metadata/flags

      parameters:

        – $ref: “#/components/parameters/AssetId”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/UpdateAssetRequest”

      responses:

        “200”:

          description: Asset updated

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Asset”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /assets/{assetId}/files:

    get:

      tags: [Assets]

      summary: Get asset file variants (signed URLs)

      parameters:

        – $ref: “#/components/parameters/AssetId”

      responses:

        “200”:

          description: File variants

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/AssetFilesResponse”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /assets/{assetId}/ratings:

    post:

      tags: [Assets]

      summary: Set rating / pick / reject / star for an asset

      parameters:

        – $ref: “#/components/parameters/AssetId”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/UpsertRatingRequest”

      responses:

        “200”:

          description: Rating upserted

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Rating”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /projects/{projectId}/clusters:

    get:

      tags: [Clusters]

      summary: List clusters (moments/bursts/duplicate groups)

      parameters:

        – $ref: “#/components/parameters/ProjectId”

        – name: kind

          in: query

          schema:

            type: string

            enum: [moment, burst, duplicate_group]

        – $ref: “#/components/parameters/Cursor”

        – $ref: “#/components/parameters/Limit”

      responses:

        “200”:

          description: Clusters page

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/PagedClusters”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /clusters/{clusterId}:

    get:

      tags: [Clusters]

      summary: Get cluster details

      parameters:

        – $ref: “#/components/parameters/ClusterId”

      responses:

        “200”:

          description: Cluster

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Cluster”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

    patch:

      tags: [Clusters]

      summary: Update cluster (rename, split/merge flags, manual overrides)

      parameters:

        – $ref: “#/components/parameters/ClusterId”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/UpdateClusterRequest”

      responses:

        “200”:

          description: Cluster updated

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Cluster”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /clusters/{clusterId}/winner:

    post:

      tags: [Clusters]

      summary: Set cluster winner

      parameters:

        – $ref: “#/components/parameters/ClusterId”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/SetWinnerRequest”

      responses:

        “200”:

          description: Winner updated

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Cluster”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /projects/{projectId}/cull:applyAction:

    post:

      tags: [Culling]

      summary: Apply bulk culling actions (pick/reject/rate)

      parameters:

        – $ref: “#/components/parameters/ProjectId”

        – $ref: “#/components/parameters/IdempotencyKey”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/BulkCullActionRequest”

      responses:

        “200”:

          description: Actions applied

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/BulkCullActionResponse”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /assets/{assetId}/edits:

    get:

      tags: [Edits]

      summary: List edit versions for an asset

      parameters:

        – $ref: “#/components/parameters/AssetId”

      responses:

        “200”:

          description: Edit versions

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/EditVersionList”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

    post:

      tags: [Edits]

      summary: Create new edit version for an asset

      parameters:

        – $ref: “#/components/parameters/AssetId”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/CreateEditVersionRequest”

      responses:

        “201”:

          description: Edit version created

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/EditVersion”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /edits/{editId}:

    get:

      tags: [Edits]

      summary: Get edit version

      parameters:

        – $ref: “#/components/parameters/EditId”

      responses:

        “200”:

          description: Edit version

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/EditVersion”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /edits/{editId}/applyTo:

    post:

      tags: [Edits]

      summary: Apply an edit version to many assets (batch)

      parameters:

        – $ref: “#/components/parameters/EditId”

        – $ref: “#/components/parameters/IdempotencyKey”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/ApplyEditBatchRequest”

      responses:

        “200”:

          description: Batch queued/applied

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/ApplyEditBatchResponse”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

  /projects/{projectId}/search:

    get:

      tags: [Search]

      summary: Simple semantic search

      parameters:

        – $ref: “#/components/parameters/ProjectId”

        – name: q

          in: query

          required: true

          schema: { type: string, minLength: 1 }

        – $ref: “#/components/parameters/Limit”

      responses:

        “200”:

          description: Search results

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/SearchResponse”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

    post:

      tags: [Search]

      summary: Advanced search (semantic + filters)

      parameters:

        – $ref: “#/components/parameters/ProjectId”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/AdvancedSearchRequest”

      responses:

        “200”:

          description: Search results

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/SearchResponse”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

  /projects/{projectId}/galleries:

    post:

      tags: [Galleries]

      summary: Create client gallery

      parameters:

        – $ref: “#/components/parameters/ProjectId”

        – $ref: “#/components/parameters/IdempotencyKey”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/CreateGalleryRequest”

      responses:

        “201”:

          description: Gallery created

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Gallery”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

  /galleries/{galleryId}:

    get:

      tags: [Galleries]

      summary: Get gallery (owner/admin)

      parameters:

        – $ref: “#/components/parameters/GalleryId”

      responses:

        “200”:

          description: Gallery

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Gallery”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

    patch:

      tags: [Galleries]

      summary: Update gallery settings

      parameters:

        – $ref: “#/components/parameters/GalleryId”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/UpdateGalleryRequest”

      responses:

        “200”:

          description: Gallery updated

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Gallery”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

  /galleries/{galleryId}/assets:

    post:

      tags: [Galleries]

      summary: Add assets to gallery

      parameters:

        – $ref: “#/components/parameters/GalleryId”

        – $ref: “#/components/parameters/IdempotencyKey”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/AddGalleryAssetsRequest”

      responses:

        “200”:

          description: Assets added

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Gallery”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

    delete:

      tags: [Galleries]

      summary: Remove assets from gallery

      parameters:

        – $ref: “#/components/parameters/GalleryId”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/RemoveGalleryAssetsRequest”

      responses:

        “200”:

          description: Assets removed

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Gallery”

        “401”:

          $ref: “#/components/responses/Unauthorized”

  /share/{shareSlug}:

    get:

      tags: [Galleries]

      summary: Public gallery view (client)

      description: |

        Returns gallery + asset previews for clients. If password-protected, returns requiresAuth=true.

      security: []

      parameters:

        – $ref: “#/components/parameters/ShareSlug”

      responses:

        “200”:

          description: Public gallery

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/PublicGalleryResponse”

        “404”:

          $ref: “#/components/responses/NotFound”

        “429”:

          $ref: “#/components/responses/RateLimited”

  /share/{shareSlug}/auth:

    post:

      tags: [Galleries]

      summary: Authenticate into a password-protected public gallery

      security: []

      parameters:

        – $ref: “#/components/parameters/ShareSlug”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/PublicGalleryAuthRequest”

      responses:

        “200”:

          description: Auth ok

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/PublicGalleryAuthResponse”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “429”:

          $ref: “#/components/responses/RateLimited”

  /share/{shareSlug}/favorite:

    post:

      tags: [Galleries]

      summary: Favorite/unfavorite an asset (client)

      security: []

      parameters:

        – $ref: “#/components/parameters/ShareSlug”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/PublicFavoriteRequest”

      responses:

        “200”:

          description: Updated

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/PublicFavoriteResponse”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “404”:

          $ref: “#/components/responses/NotFound”

        “429”:

          $ref: “#/components/responses/RateLimited”

  /share/{shareSlug}/comment:

    post:

      tags: [Galleries]

      summary: Comment on an asset (client)

      security: []

      parameters:

        – $ref: “#/components/parameters/ShareSlug”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/PublicCommentRequest”

      responses:

        “201”:

          description: Comment created

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/PublicCommentResponse”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “404”:

          $ref: “#/components/responses/NotFound”

        “429”:

          $ref: “#/components/responses/RateLimited”

  /projects/{projectId}/exports:

    post:

      tags: [Exports]

      summary: Create export job

      parameters:

        – $ref: “#/components/parameters/ProjectId”

        – $ref: “#/components/parameters/IdempotencyKey”

      requestBody:

        required: true

        content:

          application/json:

            schema:

              $ref: “#/components/schemas/CreateExportRequest”

      responses:

        “201”:

          description: Export created

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Export”

        “400”:

          $ref: “#/components/responses/BadRequest”

        “401”:

          $ref: “#/components/responses/Unauthorized”

  /exports/{exportId}:

    get:

      tags: [Exports]

      summary: Get export status

      parameters:

        – $ref: “#/components/parameters/ExportId”

      responses:

        “200”:

          description: Export

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/Export”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /exports/{exportId}/download:

    get:

      tags: [Exports]

      summary: Get signed download URL for export artifact

      parameters:

        – $ref: “#/components/parameters/ExportId”

      responses:

        “200”:

          description: Download info

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/ExportDownloadResponse”

        “401”:

          $ref: “#/components/responses/Unauthorized”

        “404”:

          $ref: “#/components/responses/NotFound”

  /projects/{projectId}/jobs:

    get:

      tags: [Jobs]

      summary: List processing jobs for a project

      parameters:

        – $ref: “#/components/parameters/ProjectId”

        – name: status

          in: query

          schema: { type: string, enum: [queued, running, done, failed, canceled] }

        – name: type

          in: query

          schema: { type: string }

        – $ref: “#/components/parameters/Cursor”

        – $ref: “#/components/parameters/Limit”

      responses:

        “200”:

          description: Jobs page

          content:

            application/json:

              schema:

                $ref: “#/components/schemas/PagedJobs”

        “401”:

          $ref: “#/components/responses/Unauthorized”

  /realtime:

    get:

      tags: [Realtime]

      summary: Server-Sent Events stream for job progress and project updates

      description: |

        SSE stream (text/event-stream). Client supplies projectId and optional lastEventId.

        Events:

          – job.progress

          – job.done

          – cluster.updated

          – asset.updated

          – export.updated

      parameters:

        – name: projectId

          in: query

          required: true

          schema: { type: string, format: uuid }

        – name: lastEventId

          in: query

          required: false

          schema: { type: string }

      responses:

        “200”:

          description: SSE stream

          content:

            text/event-stream:

              schema:

                type: string

        “401”:

          $ref: “#/components/responses/Unauthorized”

components:

  securitySchemes:

    bearerAuth:

      type: http

      scheme: bearer

      bearerFormat: JWT

  parameters:

    ProjectId:

      name: projectId

      in: path

      required: true

      schema: { type: string, format: uuid }

    AssetId:

      name: assetId

      in: path

      required: true

      schema: { type: string, format: uuid }

    ClusterId:

      name: clusterId

      in: path

      required: true

      schema: { type: string, format: uuid }

    EditId:

      name: editId

      in: path

      required: true

      schema: { type: string, format: uuid }

    GalleryId:

      name: galleryId

      in: path

      required: true

      schema: { type: string, format: uuid }

    ExportId:

      name: exportId

      in: path

      required: true

      schema: { type: string, format: uuid }

    ShareSlug:

      name: shareSlug

      in: path

      required: true

      schema: { type: string, minLength: 8, maxLength: 128 }

    Cursor:

      name: cursor

      in: query

      required: false

      schema: { type: string }

    Limit:

      name: limit

      in: query

      required: false

      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }

    IdempotencyKey:

      name: Idempotency-Key

      in: header

      required: false

      schema: { type: string, minLength: 8, maxLength: 128 }

  responses:

    BadRequest:

      description: Bad request

      content:

        application/json:

          schema: { $ref: “#/components/schemas/Error” }

    Unauthorized:

      description: Unauthorized

      content:

        application/json:

          schema: { $ref: “#/components/schemas/Error” }

    NotFound:

      description: Not found

      content:

        application/json:

          schema: { $ref: “#/components/schemas/Error” }

    Conflict:

      description: Conflict

      content:

        application/json:

          schema: { $ref: “#/components/schemas/Error” }

    RateLimited:

      description: Too many requests

      content:

        application/json:

          schema: { $ref: “#/components/schemas/Error” }

  schemas:

    Error:

      type: object

      required: [code, message]

      properties:

        code: { type: string, example: “bad_request” }

        message: { type: string, example: “Invalid input.” }

        details: { type: object, additionalProperties: true }

        requestId: { type: string }

    LoginStartRequest:

      type: object

      required: [email]

      properties:

        email: { type: string, format: email }

        locale: { type: string, example: “en-US” }

    LoginStartResponse:

      type: object

      required: [challengeId]

      properties:

        challengeId: { type: string }

        delivery: { type: string, enum: [email_code, magic_link], example: “email_code” }

    LoginVerifyRequest:

      type: object

      required: [challengeId, code]

      properties:

        challengeId: { type: string }

        code: { type: string, minLength: 4, maxLength: 12 }

    LoginVerifyResponse:

      type: object

      required: [accessToken, refreshToken, user]

      properties:

        accessToken: { type: string }

        refreshToken: { type: string }

        user: { $ref: “#/components/schemas/User” }

    User:

      type: object

      required: [id, email, displayName, createdAt]

      properties:

        id: { type: string, format: uuid }

        email: { type: string, format: email }

        displayName: { type: string }

        createdAt: { type: string, format: date-time }

    Project:

      type: object

      required: [id, title, status, createdAt, updatedAt]

      properties:

        id: { type: string, format: uuid }

        studioId: { type: string, format: uuid, nullable: true }

        ownerUserId: { type: string, format: uuid }

        title: { type: string }

        description: { type: string, nullable: true }

        shootDate: { type: string, format: date, nullable: true }

        timezone: { type: string, nullable: true }

        status: { type: string, enum: [active, archived, deleted] }

        stats:

          type: object

          additionalProperties: true

          example:

            assetCount: 1243

            pickedCount: 312

            rejectedCount: 721

            clustersReviewed: 85

        createdAt: { type: string, format: date-time }

        updatedAt: { type: string, format: date-time }

    CreateProjectRequest:

      type: object

      required: [title]

      properties:

        title: { type: string }

        description: { type: string }

        shootDate: { type: string, format: date }

        timezone: { type: string }

    UpdateProjectRequest:

      type: object

      properties:

        title: { type: string }

        description: { type: string, nullable: true }

        shootDate: { type: string, format: date, nullable: true }

        timezone: { type: string, nullable: true }

        status: { type: string, enum: [active, archived, deleted] }

    Asset:

      type: object

      required: [id, projectId, ingestedAt]

      properties:

        id: { type: string, format: uuid }

        projectId: { type: string, format: uuid }

        capturedAt: { type: string, format: date-time, nullable: true }

        ingestedAt: { type: string, format: date-time }

        widthPx: { type: integer, nullable: true }

        heightPx: { type: integer, nullable: true }

        cameraMake: { type: string, nullable: true }

        cameraModel: { type: string, nullable: true }

        lensModel: { type: string, nullable: true }

        focalLengthMm: { type: number, nullable: true }

        shutterSpeed: { type: string, nullable: true }

        aperture: { type: number, nullable: true }

        iso: { type: integer, nullable: true }

        exif: { type: object, additionalProperties: true }

        iptc: { type: object, additionalProperties: true }

        flags:

          type: object

          additionalProperties: true

          example:

            isDuplicate: false

            hasFace: false

            processingReady: true

        myRating:

          $ref: “#/components/schemas/Rating”

        bestPreviewUrl:

          type: string

          nullable: true

          description: Convenience signed URL for UI grid (short-lived).

    UpdateAssetRequest:

      type: object

      properties:

        iptc:

          type: object

          additionalProperties: true

        keywords:

          type: array

          items: { type: string }

        notes: { type: string, nullable: true }

        flags:

          type: object

          additionalProperties: true

    AssetFile:

      type: object

      required: [kind, url]

      properties:

        kind: { type: string, enum: [original, thumbnail, preview, export] }

        url: { type: string }

        contentType: { type: string, nullable: true }

        byteSize: { type: integer, format: int64, nullable: true }

        checksumSha256: { type: string, nullable: true }

        expiresAt: { type: string, format: date-time, nullable: true }

    AssetFilesResponse:

      type: object

      required: [files]

      properties:

        files:

          type: array

          items: { $ref: “#/components/schemas/AssetFile” }

    Rating:

      type: object

      required: [assetId, userId]

      properties:

        assetId: { type: string, format: uuid }

        userId: { type: string, format: uuid }

        rating: { type: integer, minimum: 1, maximum: 5, nullable: true }

        picked: { type: boolean, nullable: true }

        rejected: { type: boolean, nullable: true }

        starred: { type: boolean, nullable: true }

        notes: { type: string, nullable: true }

        updatedAt: { type: string, format: date-time, nullable: true }

    UpsertRatingRequest:

      type: object

      properties:

        rating: { type: integer, minimum: 1, maximum: 5, nullable: true }

        picked: { type: boolean, nullable: true }

        rejected: { type: boolean, nullable: true }

        starred: { type: boolean, nullable: true }

        notes: { type: string, nullable: true }

    Cluster:

      type: object

      required: [id, projectId, kind, assets]

      properties:

        id: { type: string, format: uuid }

        projectId: { type: string, format: uuid }

        kind: { type: string, enum: [moment, burst, duplicate_group] }

        title: { type: string, nullable: true }

        startTime: { type: string, format: date-time, nullable: true }

        endTime: { type: string, format: date-time, nullable: true }

        score: { type: number, nullable: true }

        reviewed: { type: boolean, nullable: true }

        winnerAssetId: { type: string, format: uuid, nullable: true }

        whyWinner:

          type: array

          items: { type: string }

          example: [“Sharpest frame”, “Eyes open”, “Cleaner background”]

        assets:

          type: array

          items:

            $ref: “#/components/schemas/ClusterAsset”

    ClusterAsset:

      type: object

      required: [assetId, rank]

      properties:

        assetId: { type: string, format: uuid }

        rank: { type: integer, minimum: 1 }

        role: { type: string, enum: [candidate, winner, alt], nullable: true }

        signals:

          type: object

          additionalProperties: true

          example:

            sharpness: 0.91

            blur: 0.05

            redundancy: 0.72

            aesthetic: 0.64

    UpdateClusterRequest:

      type: object

      properties:

        title: { type: string, nullable: true }

        reviewed: { type: boolean, nullable: true }

        manualOverrides:

          type: object

          additionalProperties: true

          description: For future split/merge mechanics.

    SetWinnerRequest:

      type: object

      required: [winnerAssetId]

      properties:

        winnerAssetId: { type: string, format: uuid }

    PrepareUploadRequest:

      type: object

      required: [files]

      properties:

        files:

          type: array

          minItems: 1

          items:

            $ref: “#/components/schemas/UploadFileDescriptor”

    UploadFileDescriptor:

      type: object

      required: [filename, byteSize]

      properties:

        clientFileId:

          type: string

          nullable: true

          description: Local identifier for mapping UI rows to responses.

        filename: { type: string }

        byteSize: { type: integer, format: int64 }

        contentType: { type: string, nullable: true }

        capturedAt: { type: string, format: date-time, nullable: true }

    PrepareUploadResponse:

      type: object

      required: [uploads]

      properties:

        uploads:

          type: array

          items:

            $ref: “#/components/schemas/UploadInstruction”

    UploadInstruction:

      type: object

      required: [assetId, uploadUrl]

      properties:

        clientFileId: { type: string, nullable: true }

        assetId: { type: string, format: uuid }

        uploadUrl: { type: string }

        headers:

          type: object

          additionalProperties: { type: string }

        expiresAt: { type: string, format: date-time, nullable: true }

    FinalizeUploadRequest:

      type: object

      required: [assets]

      properties:

        assets:

          type: array

          minItems: 1

          items:

            type: object

            required: [assetId, checksumSha256]

            properties:

              assetId: { type: string, format: uuid }

              checksumSha256: { type: string }

              contentType: { type: string, nullable: true }

    FinalizeUploadResponse:

      type: object

      required: [queuedJobs]

      properties:

        queuedJobs:

          type: array

          items: { type: string, format: uuid }

    BulkCullActionRequest:

      type: object

      required: [actions]

      properties:

        actions:

          type: array

          minItems: 1

          items:

            type: object

            required: [assetId]

            properties:

              assetId: { type: string, format: uuid }

              picked: { type: boolean, nullable: true }

              rejected: { type: boolean, nullable: true }

              rating: { type: integer, minimum: 1, maximum: 5, nullable: true }

              starred: { type: boolean, nullable: true }

    BulkCullActionResponse:

      type: object

      required: [ok]

      properties:

        ok: { type: boolean }

        updatedAssetIds:

          type: array

          items: { type: string, format: uuid }

    EditVersion:

      type: object

      required: [id, assetId, userId, params, createdAt]

      properties:

        id: { type: string, format: uuid }

        assetId: { type: string, format: uuid }

        userId: { type: string, format: uuid }

        parentId: { type: string, format: uuid, nullable: true }

        name: { type: string, nullable: true }

        params:

          type: object

          additionalProperties: true

          description: Non-destructive edit parameter set.

        createdAt: { type: string, format: date-time }

    EditVersionList:

      type: object

      required: [items]

      properties:

        items:

          type: array

          items: { $ref: “#/components/schemas/EditVersion” }

    CreateEditVersionRequest:

      type: object

      required: [params]

      properties:

        name: { type: string, nullable: true }

        parentId: { type: string, format: uuid, nullable: true }

        params:

          type: object

          additionalProperties: true

    ApplyEditBatchRequest:

      type: object

      required: [assetIds]

      properties:

        assetIds:

          type: array

          minItems: 1

          items: { type: string, format: uuid }

        mode:

          type: string

          enum: [create_versions, overwrite_latest]

          default: create_versions

        name:

          type: string

          nullable: true

    ApplyEditBatchResponse:

      type: object

      required: [jobId]

      properties:

        jobId: { type: string, format: uuid }

        status: { type: string, enum: [queued, running, done] }

    SearchResponse:

      type: object

      required: [results]

      properties:

        results:

          type: array

          items:

            type: object

            required: [assetId, score]

            properties:

              assetId: { type: string, format: uuid }

              score: { type: number }

              highlights:

                type: array

                items: { type: string }

        tookMs: { type: integer }

    AdvancedSearchRequest:

      type: object

      required: [q]

      properties:

        q: { type: string }

        filters:

          type: object

          additionalProperties: true

          example:

            picked: true

            ratingMin: 4

            cameraModel: [“Sony A7IV”]

            dateRange:

              start: “2025-01-01”

              end: “2025-12-31”

    Gallery:

      type: object

      required: [id, projectId, title, shareSlug, createdAt]

      properties:

        id: { type: string, format: uuid }

        projectId: { type: string, format: uuid }

        title: { type: string }

        shareSlug: { type: string }

        expiresAt: { type: string, format: date-time, nullable: true }

        watermark: { type: boolean }

        allowDownloads: { type: boolean }

        requiresPassword: { type: boolean }

        assetCount: { type: integer }

        createdAt: { type: string, format: date-time }

    CreateGalleryRequest:

      type: object

      required: [title, assetIds]

      properties:

        title: { type: string }

        assetIds:

          type: array

          minItems: 1

          items: { type: string, format: uuid }

        password:

          type: string

          nullable: true

          description: If set, gallery is password-protected.

        expiresAt: { type: string, format: date-time, nullable: true }

        watermark: { type: boolean, default: false }

        allowDownloads: { type: boolean, default: false }

    UpdateGalleryRequest:

      type: object

      properties:

        title: { type: string }

        password:

          type: string

          nullable: true

        expiresAt: { type: string, format: date-time, nullable: true }

        watermark: { type: boolean }

        allowDownloads: { type: boolean }

    AddGalleryAssetsRequest:

      type: object

      required: [assetIds]

      properties:

        assetIds:

          type: array

          minItems: 1

          items: { type: string, format: uuid }

    RemoveGalleryAssetsRequest:

      type: object

      required: [assetIds]

      properties:

        assetIds:

          type: array

          minItems: 1

          items: { type: string, format: uuid }

    PublicGalleryResponse:

      type: object

      required: [title, requiresAuth]

      properties:

        title: { type: string }

        requiresAuth: { type: boolean }

        token:

          type: string

          nullable: true

          description: Client session token (short-lived) if already authenticated.

        assets:

          type: array

          items:

            type: object

            required: [assetId, previewUrl]

            properties:

              assetId: { type: string, format: uuid }

              previewUrl: { type: string }

              favoriteCount: { type: integer, nullable: true }

              commentsCount: { type: integer, nullable: true }

    PublicGalleryAuthRequest:

      type: object

      required: [password]

      properties:

        password: { type: string, minLength: 1 }

    PublicGalleryAuthResponse:

      type: object

      required: [token]

      properties:

        token: { type: string }

    PublicFavoriteRequest:

      type: object

      required: [assetId, favorite]

      properties:

        assetId: { type: string, format: uuid }

        favorite: { type: boolean }

    PublicFavoriteResponse:

      type: object

      required: [ok]

      properties:

        ok: { type: boolean }

    PublicCommentRequest:

      type: object

      required: [assetId, text]

      properties:

        assetId: { type: string, format: uuid }

        text: { type: string, minLength: 1, maxLength: 2000 }

    PublicCommentResponse:

      type: object

      required: [commentId]

      properties:

        commentId: { type: string, format: uuid }

    Export:

      type: object

      required: [id, projectId, status, createdAt]

      properties:

        id: { type: string, format: uuid }

        projectId: { type: string, format: uuid }

        preset: { type: string, example: “instagram_carousel” }

        settings:

          type: object

          additionalProperties: true

        status: { type: string, enum: [queued, running, done, failed] }

        progress: { type: number, nullable: true }

        createdAt: { type: string, format: date-time }

    CreateExportRequest:

      type: object

      required: [preset]

      properties:

        preset:

          type: string

          enum: [full_res, web, instagram_carousel, story_9x16, contact_sheet_pdf]

        assetIds:

          type: array

          items: { type: string, format: uuid }

          nullable: true

          description: If omitted, export uses project picks by default (server policy).

        settings:

          type: object

          additionalProperties: true

          example:

            jpegQuality: 92

            longEdgePx: 3840

            watermark: false

    ExportDownloadResponse:

      type: object

      required: [url]

      properties:

        url: { type: string }

        expiresAt: { type: string, format: date-time, nullable: true }

    Job:

      type: object

      required: [id, type, status, createdAt]

      properties:

        id: { type: string, format: uuid }

        projectId: { type: string, format: uuid, nullable: true }

        assetId: { type: string, format: uuid, nullable: true }

        type: { type: string }

        status: { type: string, enum: [queued, running, done, failed, canceled] }

        progress: { type: number, nullable: true }

        error: { type: string, nullable: true }

        createdAt: { type: string, format: date-time }

        updatedAt: { type: string, format: date-time }

    PageInfo:

      type: object

      required: [nextCursor]

      properties:

        nextCursor: { type: string, nullable: true }

    PagedProjects:

      type: object

      required: [items, pageInfo]

      properties:

        items:

          type: array

          items: { $ref: “#/components/schemas/Project” }

        pageInfo: { $ref: “#/components/schemas/PageInfo” }

    PagedAssets:

      type: object

      required: [items, pageInfo]

      properties:

        items:

          type: array

          items: { $ref: “#/components/schemas/Asset” }

        pageInfo: { $ref: “#/components/schemas/PageInfo” }

    PagedClusters:

      type: object

      required: [items, pageInfo]

      properties:

        items:

          type: array

          items: { $ref: “#/components/schemas/Cluster” }

        pageInfo: { $ref: “#/components/schemas/PageInfo” }

    PagedJobs:

      type: object

      required: [items, pageInfo]

      properties:

        items:

          type: array

          items: { $ref: “#/components/schemas/Job” }

        pageInfo: { $ref: “#/components/schemas/PageInfo” }

SSE event format (recommended):

  • event: job.progress
  • data: {“jobId”:”…”,”assetId”:”…”,”progress”:0.72,”status”:”running”,”type”:”embedding”}
  • id: <monotonic-string>

Figma-ready wireframe checklist

This is the exact build list you hand to design + frontend so nothing gets “interpreted to death.”

Design tokens

  • Spacing: 4 / 8 / 12 / 16 / 24 / 32
  • Radius: 10 (cards), 8 (buttons), 6 (inputs)
  • Typography:
    • Display (project titles)
    • UI (buttons, labels)
    • Mono (metadata like ISO/shutter)
  • Elevations: 0 / 1 / 2 / 3 (only for overlays/modals)
  • Interaction: 150–220ms transitions for cull navigation + modals
  • Theme: dark-first (photo dominates), light optional later

Component inventory (Figma Components)

App shell

  • Sidebar (collapsed/expanded)
  • Top bar
  • Breadcrumb / project switcher
  • Processing status pill + queue drawer
  • Command palette modal

Project & ingest

  • Project card (with progress ring)
  • “New project” modal
  • Import source picker
  • Upload row (filename, size, status, retry)
  • Progress bar + throughput indicator
  • Error “quarantine” row

Grid & viewing

  • Photo grid tile
    • thumbnail
    • pick/reject badges
    • rating overlay
    • processing badge (e.g. “embedding…”)
  • Filmstrip (scrollable)
  • Image canvas viewer
  • Zoom toggle + 100% loupe
  • Compare viewer (2-up, 4-up)

Culling

  • Cluster list item (“moment stack”)
  • Cluster header (reviewed state, confidence)
  • “Winner suggested” badge
  • Key-hint overlay (optional toggle)
  • “Why winner” chips (3 max, clickable)
  • Cluster split/merge controls (drawer)

Editing

  • Slider row
  • Curve editor (minimal, V1 can be hidden behind “Advanced”)
  • HSL rows
  • WB controls
  • “Apply Style DNA” CTA + confidence meter
  • Version list (timeline)
  • Before/After toggle

Search

  • Search bar + filter chips
  • Filter drawer (camera, date, rating, picked)
  • Saved Smart Collection list item

Client gallery

  • Gallery builder stepper
  • Share link card + copy button
  • Settings toggles (watermark/download/password)
  • Client grid tile (favorite + comment count)
  • Comment drawer
  • “Selections summary” panel

Exports

  • Export preset card
  • Export job row (status, progress, download)

System UI

  • Toasts (success/error)
  • Skeleton loaders
  • Empty states
  • Confirmation dialogs

Screen specs (states + interactions)

1) Projects

States

  • Empty (first run)
  • List of projects
  • Loading
  • Error

Interactions

  • Create project (modal)
  • Jump actions: Import / Cull / Gallery

2) Ingest

States

  • Source selection
  • Upload in progress (resumable)
  • Processing pipeline progress
  • Partial-ready (thumbnails ready, AI still cooking)
  • Errors (corrupted, permission, storage quota)

Must-have interactions

  • “Go to Cull” becomes active immediately
  • “Resume upload” if interrupted

3) Cull (primary performance screen)

States

  • AI not ready (fallback: chronological filmstrip + basic blur score)
  • AI ready (clusters + winner suggestions)
  • Cluster locked (reviewed)
  • Compare mode
  • Undo/redo

Keyboard

  • F pick winner + advance
  • D reject (toggle); Shift+D reject cluster
  • 1–5 rating
  • S star
  • C compare
  • Arrow keys navigate
  • Z undo

Micro-interactions

  • After picking winner: cluster item marks reviewed + auto-advance
  • Explainability chips appear under winner (max 3)

4) Edit

States

  • No edit selected
  • Style DNA suggestion available (confidence)
  • Style DNA low confidence (prompts “choose a hero frame”)
  • Batch apply in progress
  • Version history

Interactions

  • Apply to selected
  • Sync from hero
  • Before/after toggle

5) Library/Search

States

  • Query empty: show Recents + Smart Collections
  • Results grid
  • Filters applied
  • Save Smart Collection (name prompt)

Interactions

  • Command palette drives everything

6) Client gallery builder

States

  • Step 1: choose set
  • Step 2: settings
  • Step 3: publish
  • Step 4: monitor activity

Client view states

  • Requires password
  • Favoriting
  • Commenting
  • Compare

7) Exports

States

  • Preset selection
  • Running
  • Completed (download)
  • Failed (retry)

Model card templates

Use this structure for every model shipped. It keeps your AI honest and your team sane.

Model Card Template (copy/paste)

1) Model name

  • Name:
  • Version:
  • Owner:
  • Date:

2) Overview

  • What it does:
  • Inputs:
  • Outputs:
  • Where it runs: (device / server / hybrid)
  • Latency target:
  • Dependencies:

3) Intended use

  • Primary use cases:
  • Supported content types:
  • User-facing surfaces:

4) Out of scope / prohibited use

  • Not designed for:
  • Blocked behaviors:

5) Training data

  • Source:
  • Consent / licensing:
  • Time range:
  • Representativeness notes:
  • Data minimization strategy:

6) Evaluation

  • Offline metrics:
  • Online metrics:
  • Benchmarks:
  • Regression gates (ship/no-ship criteria):

7) Limitations

  • Known failure modes:
  • Worst-case scenarios:
  • When to fallback:

8) Safety, privacy, and security

  • Sensitive attribute handling:
  • Face/person features:
  • On-device processing:
  • Data retention:
  • Audit logging:

9) Monitoring

  • Drift signals:
  • Performance alerts:
  • User feedback capture:
  • Rollback plan:

10) Change log

  • vX → vY changes:
  • Migration notes:

Filled example: Cull Winner Ranker (Personalized)

1) Model name

  • Name: CullWinnerRanker
  • Version: 1.0.0
  • Owner: ML Team
  • Date: 2026-01-14

2) Overview

  • What it does: Ranks candidates inside a cluster/burst and suggests a winner.
  • Inputs: image preview pixels (or embeddings), technical signals (sharpness/blur/exposure), context signals (burst position), user preference profile.
  • Outputs: ordered list + winner + “why winner” reasons.
  • Where it runs: server for ranking; optional lightweight on-device rescoring.
  • Latency target: < 100ms per cluster after signals are computed.

3) Intended use

  • Primary use cases:
    • Winner preselect in Cull stacks
    • “Top 3 candidates” for compare mode

4) Out of scope / prohibited use

  • Not designed to judge “beauty” or identity attributes.
  • Not used for hiring, surveillance, or sensitive inference.

5) Training data

  • Source: opt-in photographer culling logs (picked/rejected) + synthetic negatives (duplicates).
  • Consent: explicit opt-in; default is no training.
  • Minimization: store only derived features + decisions, not originals (unless explicitly opted in).

6) Evaluation

  • Offline:
    • Top-1 accuracy (winner matches human)
    • Top-3 hit rate
    • Regret rate (winner overridden)
  • Online:
    • Time-to-cull completion
    • Override rate trend after 20 picks (personalization gain)

7) Limitations

  • Failure modes:
    • Weird lighting / heavy motion blur
    • Cluttered scenes where “best” is subjective
  • Fallback:
    • Reduce confidence, suggest top 3 not top 1, show compare immediately

8) Safety, privacy, security

  • Face features are optional; if disabled, no face-derived signals used.
  • Logs are per-user, access-controlled, encrypted.

9) Monitoring

  • Drift: override rate spikes, cluster split/merge frequency spikes.
  • Rollback: instant model router revert.

10) Change log

  • v1: adds personalization layer and reason chips.

Style DNA training loop pseudocode (privacy-safe)

This is a practical V1 approach:

  • learns from your edit deltas
  • stores only lightweight stats / weights
  • supports context buckets (day/night/indoor)
  • produces a confidence score
  • never needs to upload raws by default

Data structures

from dataclasses import dataclass

from typing import Dict, List, Tuple, Optional

import numpy as np

@dataclass

class EditEvent:

    # Derived features only; no original pixels required for this training loop.

    # (You can derive these from previews locally.)

    features: np.ndarray      # e.g. 256-d image/style features

    context: np.ndarray       # e.g. 16-d lighting/camera context

    params_before: np.ndarray # baseline “Fix” params (objective)

    params_after: np.ndarray  # final user-approved params

    weight: float             # e.g. 1.0, or lower for uncertain edits

@dataclass

class RidgeModel:

    # W maps [features+context] -> delta_params

    W: np.ndarray             # shape: (d_in, d_out)

    b: np.ndarray             # shape: (d_out,)

    n: int                    # number of samples absorbed

    # For confidence: track running mean/cov of inputs (or just mean + diag var)

    mu: np.ndarray

    var: np.ndarray           # diagonal variance

@dataclass

class StyleProfile:

    # Mixture of context buckets (simple, robust)

    buckets: Dict[str, RidgeModel]

    version: str

Context bucketing (simple + effective)

def context_bucket(context: np.ndarray) -> str:

    “””

    Example: context features could include:

      – estimated CCT (color temp)

      – ISO level bucket

      – indoor/outdoor probability

      – time-of-day bucket

    “””

    cct = context[0]

    iso = context[1]

    indoor_prob = context[2]

    if indoor_prob > 0.7:

        return “indoor”

    if cct < 4200:

        return “cool_light”

    if iso > 1600:

        return “high_iso_night”

    return “daylight”

Training/update (incremental ridge regression)

We learn delta params = (user_after – baseline_fix).

That means the system’s “Fix” remains stable and the “Style DNA” is the personal layer.

def init_ridge(d_in: int, d_out: int) -> RidgeModel:

    return RidgeModel(

        W=np.zeros((d_in, d_out), dtype=np.float32),

        b=np.zeros((d_out,), dtype=np.float32),

        n=0,

        mu=np.zeros((d_in,), dtype=np.float32),

        var=np.ones((d_in,), dtype=np.float32),

    )

def update_running_stats(model: RidgeModel, x: np.ndarray, alpha: float = 0.02):

    # Exponential moving average stats for confidence

    model.mu = (1 – alpha) * model.mu + alpha * x

    diff = x – model.mu

    model.var = (1 – alpha) * model.var + alpha * (diff * diff)

def ridge_update_closed_form(

    model: RidgeModel,

    x: np.ndarray,

    y: np.ndarray,

    lr: float = 0.05,

    l2: float = 1e-2,

    sample_weight: float = 1.0,

):

    “””

    Lightweight online update (SGD-ish on ridge objective).

    Good enough for V1. Replace with true online ridge if needed later.

    “””

    # Prediction

    y_hat = x @ model.W + model.b

    err = (y_hat – y)  # shape (d_out,)

    # Gradients

    grad_W = np.outer(x, err) + l2 * model.W

    grad_b = err

    # Update

    model.W -= lr * sample_weight * grad_W

    model.b -= lr * sample_weight * grad_b

    model.n += 1

def absorb_edit_event(profile: StyleProfile, e: EditEvent):

    x = np.concatenate([e.features, e.context]).astype(np.float32)

    y = (e.params_after – e.params_before).astype(np.float32)  # delta params

    b = context_bucket(e.context)

    if b not in profile.buckets:

        profile.buckets[b] = init_ridge(d_in=x.shape[0], d_out=y.shape[0])

    m = profile.buckets[b]

    update_running_stats(m, x)

    ridge_update_closed_form(m, x, y, lr=0.03, l2=5e-3, sample_weight=e.weight)

Inference (apply Style DNA + confidence gate)

def confidence_score(model: RidgeModel, x: np.ndarray) -> float:

    “””

    Confidence based on normalized distance from training distribution.

    Higher distance = lower confidence.

    “””

    z = (x – model.mu) / (np.sqrt(model.var) + 1e-6)

    dist = float(np.sqrt(np.mean(z * z)))

    # Map distance -> confidence in [0,1]

    return float(np.clip(np.exp(-0.7 * dist), 0.0, 1.0))

def predict_style_delta(profile: StyleProfile, features: np.ndarray, context: np.ndarray) -> Tuple[np.ndarray, float, str]:

    x = np.concatenate([features, context]).astype(np.float32)

    b = context_bucket(context)

    if b not in profile.buckets or profile.buckets[b].n < 25:

        # Not enough data: low confidence fallback

        return np.zeros((0,), dtype=np.float32), 0.0, b

    m = profile.buckets[b]

    conf = confidence_score(m, x)

    delta = x @ m.W + m.b

    return delta, conf, b

def apply_style_dna(

    baseline_fix_params: np.ndarray,

    predicted_delta: np.ndarray,

    conf: float,

    conf_threshold: float = 0.55,

) -> np.ndarray:

    if conf < conf_threshold:

        # Fallback: apply only baseline fixes (objective)

        return baseline_fix_params

    # Clamp deltas to sane ranges to avoid wild edits

    delta_clamped = np.clip(predicted_delta, -0.5, 0.5)  # example

    return baseline_fix_params + delta_clamped

Privacy-safe logging (default)

  • Store only:
    • hashed asset id
    • derived feature vectors (optionally quantized)
    • context features
    • params delta
  • Don’t store:
    • originals
    • previews
    • face embeddings (unless opt-in and local)

Optional opt-in “improve global model” path:

  • send noised aggregates (DP-ish) like mean deltas per bucket, not raw examples

def export_aggregate_for_opt_in(profile: StyleProfile, noise_std: float = 0.02) -> Dict:

    “””

    Optional: export coarse aggregates only, with noise.

    “””

    out = {}

    for k, m in profile.buckets.items():

        if m.n < 100:

            continue

        # Export only weights + stats, optionally noise them

        W = m.W + np.random.normal(0, noise_std, size=m.W.shape).astype(np.float32)

        b = m.b + np.random.normal(0, noise_std, size=m.b.shape).astype(np.float32)

        out[k] = {“W”: W.tolist(), “b”: b.tolist(), “n”: m.n}

    return out

Extra: “Definition of Done” gates (so this ships clean)

Cull

  • Winner suggestion Top-1 accuracy ≥ 60% on beta dataset
  • After 20 picks personalization improves Top-1 ≥ +10 pts
  • Override rate trending down week-over-week

Style DNA

  • “Deliverable as-is” ≥ 65% for selected beta users (human eval)
  • Confidence gating prevents “ruined batch” incidents (hard requirement)

Search

  • Precision@10 ≥ 0.7 on labeled queries
  • Median query latency < 500ms

If you want, I can now generate the frontend contract too (TypeScript types matching the OpenAPI schemas + a client SDK layout), plus a job processor spec (queue, retries, idempotency rules, and how clustering/ranking jobs should be chained).