• Welcome to AppraisersForum.com, the premier online  community for the discussion of real estate appraisal. Register a free account to be able to post and unlock additional forums and features.

Unintended consequence of reviews

3) Prototype Matching Engine​


3.1 Python (runnable, minimal external deps)​


from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Dict, Optional
import math
import time
import hashlib
from collections import deque, defaultdict

# --- Data Models --------------------------------------------------------------

@dataclass
class SLA:
target_days: int
latest_delivery_utc: str

@dataclass
class Filters:
geo_radius_miles: int
min_experience_hours: int
licenses_required: List[str]
form: str
inspection: str

@dataclass
class Order:
order_id: str
county_fips: str
product_form: str # e.g., "1004"
inspection: str # "full" | "desktop" | "exterior"
property_type: str # "SFR" | "CONDO" | "2-4"
complexity_tier: str # "standard" | "complex"
approvals_required: List[str] # e.g., ["FHA:false","VA:false"]
sla: SLA
filters: Filters

@dataclass
class Appraiser:
appraiser_id: str
licenses: List[str]
counties: List[str]
forms: List[str]
inspections: List[str]
property_types: List[str]
experience_hours: int
approvals: Dict[str, bool] # {"FHA": False, "VA": False, ...}
quality_index: float # 0..1 (1 best)
sla_overage_rate_90d: float # 0..1 (0 best)
recent_assignments_share_90d: float # 0..1 (lower is favored)
unavailable: bool = False
conflicts: bool = False

# --- Matching Engine ----------------------------------------------------------

class MatchingEngine:
def __init__(self):
self.assignment_log: List[Dict] = []
self.recent_winners: deque[str] = deque(maxlen=1000)
self.rotation_weight: Dict[str, float] = defaultdict(float)

def eligible(self, order: Order, a: Appraiser) -> bool:
if a.unavailable or a.conflicts:
return False
if order.filters.min_experience_hours and a.experience_hours < order.filters.min_experience_hours:
return False
if not set(order.filters.licenses_required).issubset(set(a.licenses)):
return False
if order.county_fips not in a.counties:
return False
if order.product_form not in a.forms:
return False
if order.inspection not in a.inspections:
return False
if order.property_type not in a.property_types:
return False
# approvals (FHA/VA) required?
for k in ["FHA", "VA"]:
req = any(f"{k}:true" == x.lower() for x in order.approvals_required)
if req and not a.approvals.get(k, False):
return False
return True

def score(self, order: Order, a: Appraiser) -> float:
# Base: inverse recent share + inverse local rotation weight
base = 1.0 / (1.0 + max(a.recent_assignments_share_90d, 1e-6))
base += 1.0 / (1.0 + max(self.rotation_weight[a.appraiser_id], 0.0))

# Competency depth heuristic by complexity
comp = min(1.0, a.experience_hours / 4000.0) # saturate ~4k hrs
if order.complexity_tier == "complex":
comp *= 1.5

# Quality stabilizer: small cap to avoid favoritism
q_adj = min(0.15, (a.quality_index - 0.8)) # only reward above 0.8, capped

# SLA penalty: cap impact to avoid backdoor favoritism
sla_pen = min(0.2, a.sla_overage_rate_90d * 0.5)

return base + comp + q_adj - sla_pen

def match(self, order: Order, pool: List[Appraiser]) -> Dict:
elig = [a for a in pool if self.eligible(order, a)]
if not elig:
return {"status": "no_eligible", "eligible_pool_size": 0}

scored = [(self.score(order, a), a) for a in elig]
scored.sort(key=lambda t: t[0], reverse=True)
winner_score, winner = scored[0]

# update rotation memory
self.recent_winners.append(winner.appraiser_id)
self.rotation_weight[winner.appraiser_id] += 1.0
for aid in list(self.rotation_weight.keys()):
self.rotation_weight[aid] *= 0.98 # decay

audit_payload = f"{order.order_id}|{sorted([a.appraiser_id for a in elig])}|{winner.appraiser_id}|{winner_score:.5f}"
audit_hash = hashlib.sha256(audit_payload.encode()).hexdigest()

decision_card = {
"filters": {
"licenses_required": order.filters.licenses_required,
"min_experience_hours": order.filters.min_experience_hours,
"form": order.product_form,
"inspection": order.inspection
},
"eligible_pool_size": len(elig),
"top_candidates": [
{"appraiser_id": a.appraiser_id, "score": round(s, 4)}
for (s, a) in scored[:5]
],
"selection_rule": "rotation_with_capped_quality_adjustments_v1",
"audit_hash": audit_hash
}
record = {
"timestamp": int(time.time()),
"order_id": order.order_id,
"winner": winner.appraiser_id,
"decision_card": decision_card
}
self.assignment_log.append(record)
return {"status": "matched", **record}

# --- Example Usage ------------------------------------------------------------

if __name__ == "__main__":
engine = MatchingEngine()

pool = [
Appraiser("APR1", ["CA_CERTIFIED_RESIDENTIAL"], ["06081","06085"], ["1004","1073"], ["full","desktop"], ["SFR","CONDO"], 5200, {"FHA":True,"VA":False}, 0.92, 0.05, 0.02),
Appraiser("APR2", ["CA_CERTIFIED_RESIDENTIAL"], ["06081"], ["1004"], ["full"], ["SFR"], 2100, {"FHA":False,"VA":False}, 0.88, 0.02, 0.03),
Appraiser("APR3", ["CA_CERTIFIED_RESIDENTIAL"], ["06081","06075"], ["1004","2055"], ["full","exterior"], ["SFR","2-4"], 3800, {"FHA":True,"VA":True}, 0.96, 0.10, 0.01),
]

order = Order(
order_id="ORD-001",
county_fips="06081",
product_form="1004",
inspection="full",
property_type="SFR",
complexity_tier="standard",
approvals_required=["FHA:false","VA:false"],
sla=SLA(target_days=7, latest_delivery_utc="2025-10-15T00:00:00Z"),
filters=Filters(
geo_radius_miles=25,
min_experience_hours=2000,
licenses_required=["CA_CERTIFIED_RESIDENTIAL"],
form="1004",
inspection="full"
)
)

result = engine.match(order, pool)
print(result)

What this does:
  • Filters by license, geography, form, inspection, property type, approvals, experience, and availability/conflicts.
  • Scores with a rotation-friendly base (punishes recent over-assignment), complexity-sensitive competency boost, capped quality bonus, and capped SLA penalty.
  • Produces a compact decision_card with the top candidates and an audit_hash to anchor logs.
 
3.2 SWI-Prolog (eligibility + selection skeleton)

% --- Knowledge Base -----------------------------------------------------------
% appraiser(Id, Licenses, Counties, Forms, Inspections, PropTypes, ExpHours, FHA, VA, QualityIndex, SLAOver90, RecentShare90, Unavailable, Conflicts).

appraiser(apr1,
[ca_certified_residential],
[ '06081','06085' ],
[ '1004','1073' ],
[ full, desktop ],
[ SFR, condo ],
5200, true, false, 0.92, 0.05, 0.02, false, false).

appraiser(apr2,
[ca_certified_residential],
[ '06081' ],
[ '1004' ],
[ full ],
[ SFR ],
2100, false, false, 0.88, 0.02, 0.03, false, false).

appraiser(apr3,
[ca_certified_residential],
[ '06081','06075' ],
[ '1004','2055' ],
[ full, exterior ],
[ SFR, '2-4' ],
3800, true, true, 0.96, 0.10, 0.01, false, false).

% order(OrderId, CountyFips, Form, Inspection, PropType, Complexity, ReqFHA, ReqVA, MinExp, LicensesRequired).
order(ord001, '06081', '1004', full, SFR, standard, false, false, 2000, [ca_certified_residential]).

subset([], _).
subset([H|T], set(S)) :- member(H, S), subset(T, set(S)).

eligible(OrderId, AppraiserId) :-
order(OrderId, County, Form, Inspect, Prop, _Complex, ReqFHA, ReqVA, MinExp, LicReq),
appraiser(AppraiserId, Lics, Counties, Forms, Insps, Props, Exp, FHA, VA, _Q, _SLA, _RS, Unavail, Conf),
\+ Unavail, \+ Conf,
subset(LicReq, set(Lics)),
member(County, Counties),
member(Form, Forms),
member(Inspect, Insps),
member(Prop, Props),
Exp >= MinExp,
(ReqFHA -> FHA = true ; true),
(ReqVA -> VA = true ; true).

% Simple scoring (extend with your rotation store via dynamic predicates)
score(OrderId, AppraiserId, Score) :-
order(OrderId, _C,_F,_I,_P,Complex, _FH,_VA,_Min,_LicReq),
appraiser(AppraiserId,_L,_Cs,_Fs,_Is,_Ps,Exp,_F,_V,Q,SLA,RS,_U,_C),
Base is 1.0 / (1.0 + RS),
Comp0 is min(1.0, Exp / 4000.0),
(Complex == complex -> Comp is Comp0 * 1.5 ; Comp = Comp0),
QAdj is min(0.15, max(0.0, Q - 0.8)),
SLAPen is min(0.2, SLA * 0.5),
Score is Base + Comp + QAdj - SLAPen.

best_match(OrderId, Winner, WinnerScore) :-
findall(A, eligible(OrderId, A), Elig),
findall(S-A, (member(A, Elig), score(OrderId, A, S)), Pairs),
keysort(Pairs, Sorted), reverse(Sorted, [WinnerScore-Winner|_]).

/** Example query:
?- best_match(ord001, W, S).
*/

Notes for Prolog use:
  • For rotation memory, add dynamic predicates (e.g., recent_win(Apr, Count)) with decay over time in score/3.
  • Keep quality/SLA caps to avoid covert favoritism through “quality” knobs.



Closing Observations (opinionated)​

  • The governance shape is what makes this survive real-world scrutiny: active state supervision, narrow mandate, no pricing, and immutable logs.
  • The matching math must remain simple, explainable, and bounded. The purpose is independence and fairness, not to reinvent credit risk models.
  • The API receipts (decision cards + audit hashes) are the practical linchpin: they let examiners and courts reconstruct, on the record, how each assignment was made.

If you would like, I can now (a) turn the legislative brief into bill-ready statutory sections with placeholders for your state code titles, (b) expand the Python into a small Flask/FastAPI service that implements the above endpoints, and (c) add CSV importers for your current panel/assignment histories to simulate outcomes and fairness indices."
 
The prior code is a mixture of Python and Prolog.

As you can see, we live in a NEW AGE.
 
ChatGPT: " Bert,
Per your request, here are all three deliverables—ready to use:
  1. bill-ready statutory language (modular sections you can drop into a state code title),
  2. a production-style FastAPI service that implements the clearinghouse endpoints and logs, and
  3. CSV importers plus baseline analytics/fairness metrics to pilot with your own panels and historical assignments.
I have written everything to be copy-pastable. Where a choice of names is jurisdiction-specific, I use clear bracketed placeholders.

I) Bill-Ready Statutory Sections​

Drafting note: These sections assume your code organization resembles “Title [XX]—Financial Institutions / Professional Regulation.” Search/replace [STATE], [Supervising Agency], etc. Keep “active supervision” language intact for antitrust state-action protection.

Section 1. Short Title; Findings; Purpose.​

(a) Short Title. This Act may be cited as the Appraisal Order Independence and Transparency Act of [YEAR].

(b) Legislative Findings.
  1. The Legislature finds that independence in real estate appraisal selection is essential to consumer protection and safe and sound lending.
  2. Existing federal and state rules mitigate—but do not eliminate—economic incentives that may bias appraiser selection.
  3. A narrowly-tailored, state-chartered clearinghouse that allocates appraisal orders through objective competency filters and transparent rotation promotes independence, competition, and fairness without setting prices or regulating market participants beyond licensure status.
(c) Purpose. To establish a state-chartered, privately operated, publicly supervised appraisal-order clearinghouse that (i) routes appraisal orders among qualified appraisers using objective filters and transparent rotation; (ii) preserves lender responsibility for appraiser competency; (iii) creates immutable, auditable records; and (iv) avoids anticompetitive effects through active state supervision.


Section 2. Definitions.​

As used in this Act:
  1. “Clearinghouse” means the Appraisal-Order Clearinghouse established under Section 3.
  2. “Supervising Agency” means the [Department of Financial Protection and Innovation of [STATE]] (or successor).
  3. “Board” means the governing board of the Clearinghouse.
  4. “Licensed Appraiser” means an individual or entity holding an active state license or certification to perform real estate appraisals under [STATE] law.
  5. “Participant Lender/AMC” means a financial institution or appraisal management company that submits appraisal orders to the Clearinghouse.
  6. “Objective Filters” means non-discretionary criteria related to licensure, geography, property/product type, program approvals (e.g., FHA/VA), experience thresholds, and service-level windows, but excluding naming of specific appraisers or fee amounts.
  7. “Rotation” means a selection process that distributes assignments across an eligible pool using transparent, bounded rules, allowing only capped adjustments for documented competency, quality, and service-level reliability.
  8. “Decision Card” means a machine-readable record generated by the Clearinghouse documenting the eligible pool, scoring inputs, selection rule, and audit hash for a given assignment.

Section 3. Establishment; Corporate Form; Independence.​

(a) There is established a public-benefit corporation to be known as the [STATE] Appraisal-Order Clearinghouse, Inc. (“Clearinghouse”). The Clearinghouse is a body corporate and politic with perpetual succession.

(b) The Clearinghouse is not a regulatory agency and shall not license, discipline, or otherwise regulate appraisers; those functions remain with the [State Appraiser Licensing Board]. The Clearinghouse shall not set, fix, or suggest appraisal fees.

(c) The Clearinghouse shall operate on a statewide, nonexclusive basis. Participation is voluntary unless otherwise required by specific program rule.


Section 4. Governance; Active State Supervision.​

(a) Board Composition. The Clearinghouse shall be governed by a Board of 9–11 directors appointed as follows:
  1. Two officials designated by the Supervising Agency (ex officio).
  2. Two Licensed Appraisers in good standing.
  3. Two lender risk/compliance officers.
  4. One AMC compliance officer.
  5. Two public members with consumer-protection or data-ethics expertise.
  6. (Optional) One data/AI ethics expert.
(b) Conflicts; Recusal. The Board shall adopt conflict-of-interest and recusal rules applicable to directors and staff.

(c) Active Supervision.
  1. The Supervising Agency shall approve in advance and may require modification of the Clearinghouse’s matching and rotation rules, eligibility criteria, complaint/appeal procedures, transparency reports, and fee schedules.
  2. The Supervising Agency shall conduct periodic operational audits and may issue binding directives to remedy practices inconsistent with this Act’s purpose.
  3. The Supervising Agency may suspend a rule or practice of the Clearinghouse pending review.

Section 5. Powers and Duties.​

(a) Matching Function. The Clearinghouse shall:
  1. Receive appraisal orders from Participant Lenders/AMCs.
  2. Determine the eligible pool using Objective Filters.
  3. Select an appraiser via Rotation with capped adjustments for documented competency, bounded quality history, and service-level reliability.
  4. Generate a Decision Card and immutable log for each assignment.
  5. Provide secure messaging between parties as needed for acceptance/decline and ROV workflows.
(b) Prohibitions. The Clearinghouse shall not:
  1. Set or enforce appraisal fee levels.
  2. Engage in exclusive dealing or restrict entry beyond licensure and competency attestations.
  3. Permit selection based on named appraisers, except as expressly authorized by rule for bona-fide specialty or conflict-avoidance.
(c) Data; Security. The Clearinghouse shall maintain SOC 2 Type II controls or equivalent, encrypt data in transit and at rest, minimize personally identifiable information, and maintain immutable logs with retention aligned to [banking/records statutes].

(d) Transparency. The Clearinghouse shall publish quarterly aggregate, privacy-preserving statistics on assignment distribution, turn-times, dispute rates, and ROV frequency, and provide regulators with detailed, access-controlled audit reports.



Section 6. Participant Obligations.​

(a) Participant Lenders/AMCs shall submit orders using Objective Filters only.
(b) Participants shall not request or steer toward a named appraiser except as permitted by rule for specialty competency, in which case the request and justification shall be logged.
(c) Participants shall cooperate with audits and provide data necessary for verifying independence and fairness metrics.



Section 7. Appraiser Eligibility and Duties.​

(a) Licensed Appraisers may enroll by attesting to competencies (geography, property types, approvals, experience) and providing proof of E&O coverage.
(b) Appraisers shall update availability and disclose conflicts of interest.
(c) Providing false competency information is grounds for suspension from the Clearinghouse and referral to the Licensing Board.




Section 8. Complaints; Appeals; Sanctions.​

(a) The Clearinghouse shall maintain procedures for complaints and appeals by appraisers and participants.
(b) Sanctions for integrity violations may include warnings, temporary suspension, or termination of participation; regulatory matters shall be referred to the Licensing Board.
(c) All actions are subject to review by the Supervising Agency.



Section 9. Fees; Funding.​

(a) The Clearinghouse may charge cost-based, non-discriminatory transaction fees to ordering parties and tiered membership fees for API/analytics access, subject to Supervising Agency approval.
(b) The Supervising Agency may authorize grants during pilot phases.



Section 10. Rulemaking; Preemption; Construction.​

(a) The Supervising Agency shall promulgate rules to implement this Act.
(b) Nothing in this Act alters federal obligations under FIRREA, Dodd-Frank, or agency appraisal-independence requirements.
(c) This Act shall be construed narrowly to further independence and transparency while preserving competition."

(to be continued)
 

II) FastAPI Service (ready to run)​


What you get: OAuth-ready structure (placeholder), in-memory stores for demo, CSV loaders, rotation engine, decision cards with audit hashes, endpoints mirroring what we discussed. Save as app/main.py and run with uvicorn app.main:app --reload.

# app/main.py

from __future__ import annotations
from fastapi import FastAPI, HTTPException, Depends, Body
from pydantic import BaseModel, Field, constr
from typing import List, Dict, Optional
from datetime import datetime, timezone
import hashlib
import csv
from collections import defaultdict, deque

app = FastAPI(title="State Appraisal-Order Clearinghouse (Demo)", version="0.1.0")

# -------------------- Simple Auth Placeholder --------------------
def get_client_id() -> str:
# Replace with OAuth2/mTLS. For demo, accept a static client id.
return "DEMO-LENDER"

# -------------------- Data Models --------------------
class SLA(BaseModel):
target_days: int = 7
latest_delivery_utc: Optional[datetime] = None

class Filters(BaseModel):
geo_county_fips: constr(min_length=5, max_length=5)
geo_radius_miles: int = 25
min_experience_hours: int = 0
licenses_required: List[str] = []
form: str
inspection: str

class OrderIn(BaseModel):
client_order_id: str
loan_purpose: str
property_type: str
product_form: str
inspection: str
complexity_tier: str = Field(regex="^(standard|complex)$")
approvals_required: List[str] = [] # e.g. ["FHA:true","VA:false"]
sla: SLA
filters: Filters
notes: Optional[str] = None

class OrderRecord(OrderIn):
order_id: str
intake_ts: datetime
lender_id: str
status: str = "intake_pending"
assignment_id: Optional[str] = None

class DecisionCard(BaseModel):
filters: Dict
eligible_pool_size: int
top_candidates: List[Dict]
selection_rule: str
audit_hash: str

class MatchResult(BaseModel):
order_id: str
status: str
assignment_id: Optional[str] = None
appraiser_id: Optional[str] = None
decision_card: Optional[DecisionCard] = None
timestamp: datetime

class AssignmentDecisionIn(BaseModel):
decision: constr(regex="^(accept|decline)$")
reason_code: Optional[str] = None
message: Optional[str] = None

class Appraiser(BaseModel):
appraiser_id: str
licenses: List[str]
counties: List[str]
forms: List[str]
inspections: List[str]
property_types: List[str]
experience_hours: int
approvals: Dict[str, bool] # {"FHA": True/False, "VA": True/False}
quality_index: float = 0.9 # 0..1
sla_overage_rate_90d: float = 0.0 # 0..1
recent_share_90d: float = 0.0
unavailable: bool = False
conflicts: bool = False

# -------------------- In-Memory Stores (demo) --------------------
APPRAISERS: Dict[str, Appraiser] = {}
ORDERS: Dict[str, OrderRecord] = {}
ASSIGNMENTS: Dict[str, Dict] = {}
ASSIGNMENT_LOG: List[Dict] = []

# Rotation memory
RECENT_WINNERS: deque[str] = deque(maxlen=2000)
ROTATION_WEIGHT: defaultdict[str, float] = defaultdict(float)

# -------------------- Utility: Eligibility, Scoring --------------------
def approvals_required_true(reqs: List[str], key: str) -> bool:
norm = [x.strip().lower() for x in reqs]
return f"{key.lower()}:true" in norm

def eligible(order: OrderRecord, a: Appraiser) -> bool:
if a.unavailable or a.conflicts:
return False
f = order.filters
if f.min_experience_hours and a.experience_hours < f.min_experience_hours:
return False
if not set(f.licenses_required).issubset(set(a.licenses)):
return False
if f.geo_county_fips not in a.counties:
return False
if order.product_form not in a.forms:
return False
if order.inspection not in a.inspections:
return False
if order.property_type not in a.property_types:
return False
for k in ["FHA", "VA"]:
if approvals_required_true(order.approvals_required, k) and not a.approvals.get(k, False):
return False
return True

def score(order: OrderRecord, a: Appraiser) -> float:
base = 1.0 / (1.0 + max(a.recent_share_90d, 1e-6))
base += 1.0 / (1.0 + max(ROTATION_WEIGHT[a.appraiser_id], 0.0))

comp = min(1.0, a.experience_hours / 4000.0)
if order.complexity_tier == "complex":
comp *= 1.5

q_adj = min(0.15, max(0.0, a.quality_index - 0.8))
sla_pen = min(0.2, a.sla_overage_rate_90d * 0.5)
return base + comp + q_adj - sla_pen

def audit_hash(order_id: str, elig_ids: List[str], winner_id: str, score_val: float) -> str:
payload = f"{order_id}|{','.join(sorted(elig_ids))}|{winner_id}|{score_val:.5f}"
return hashlib.sha256(payload.encode()).hexdigest()

def decay_rotation():
for aid in list(ROTATION_WEIGHT.keys()):
ROTATION_WEIGHT[aid] *= 0.98

# -------------------- Endpoints --------------------
@app.post("/orders", response_model=MatchResult)
def submit_order(order: OrderIn, client_id: str = Depends(get_client_id)):
order_id = f"ORD-{hashlib.md5(order.client_order_id.encode()).hexdigest()[:8]}"
rec = OrderRecord(**order.dict(), order_id=order_id, intake_ts=datetime.now(timezone.utc), lender_id=client_id)
ORDERS[order_id] = rec

# Immediate matching (sync) for demo
elig = [a for a in APPRAISERS.values() if eligible(rec, a)]
ts = datetime.now(timezone.utc)

if not elig:
ORDERS[order_id].status = "no_eligible"
return MatchResult(order_id=order_id, status="no_eligible", timestamp=ts)

candidates = sorted([(score(rec, a), a) for a in elig], key=lambda x: x[0], reverse=True)
top_score, winner = candidates[0]

decay_rotation()
RECENT_WINNERS.append(winner.appraiser_id)
ROTATION_WEIGHT[winner.appraiser_id] += 1.0

assignment_id = f"ASN-{hashlib.md5((order_id+winner.appraiser_id).encode()).hexdigest()[:10]}"
decision = DecisionCard(
filters={
"licenses_required": rec.filters.licenses_required,
"min_experience_hours": rec.filters.min_experience_hours,
"form": rec.product_form,
"inspection": rec.inspection,
"county_fips": rec.filters.geo_county_fips
},
eligible_pool_size=len(elig),
top_candidates=[
{"appraiser_id": a.appraiser_id, "score": round(s, 4)}
for (s, a) in candidates[:5]
],
selection_rule="rotation_with_capped_quality_adjustments_v1",
audit_hash=audit_hash(order_id, [a.appraiser_id for a in elig], winner.appraiser_id, top_score)
)

ASSIGNMENTS[assignment_id] = {
"assignment_id": assignment_id,
"order_id": order_id,
"appraiser_id": winner.appraiser_id,
"status": "tentative",
"created": ts,
"decision_card": decision.dict()
}

ORDERS[order_id].status = "matched"
ORDERS[order_id].assignment_id = assignment_id

log_entry = {
"timestamp": ts.isoformat(),
"order_id": order_id,
"winner": winner.appraiser_id,
"decision_card": decision.dict()
}
ASSIGNMENT_LOG.append(log_entry)

return MatchResult(order_id=order_id, status="matched", assignment_id=assignment_id,
appraiser_id=winner.appraiser_id, decision_card=decision, timestamp=ts)

@app.get("/orders/{order_id}", response_model=MatchResult)
def get_order(order_id: str, client_id: str = Depends(get_client_id)):
if order_id not in ORDERS:
raise HTTPException(status_code=404, detail="Order not found")
rec = ORDERS[order_id]
assignment = ASSIGNMENTS.get(rec.assignment_id) if rec.assignment_id else None
ts = datetime.now(timezone.utc)
return MatchResult(
order_id=order_id,
status=rec.status,
assignment_id=rec.assignment_id,
appraiser_id=assignment["appraiser_id"] if assignment else None,
decision_card=DecisionCard(**assignment["decision_card"]) if assignment else None,
timestamp=ts
)

@app.post("/assignments/{assignment_id}/decision")
def assignment_decision(assignment_id: str, payload: AssignmentDecisionIn, client_id: str = Depends(get_client_id)):
if assignment_id not in ASSIGNMENTS:
raise HTTPException(status_code=404, detail="Assignment not found")
a = ASSIGNMENTS[assignment_id]
if payload.decision == "accept":
a["status"] = "accepted"
else:
a["status"] = "declined"
a["decline_reason"] = payload.reason_code or "unspecified"
return {"assignment_id": assignment_id, "status": a["status"]}

@app.get("/analytics/distribution")
def analytics_distribution(county_fips: str, period: Optional[str] = None):
# Minimal demo analytics
total_orders = sum(1 for o in ORDERS.values() if o.filters.geo_county_fips == county_fips)
winners = [ASSIGNMENTS[o.assignment_id]["appraiser_id"]
for o in ORDERS.values()
if o.filters.geo_county_fips == county_fips and o.assignment_id in ASSIGNMENTS]
counts = defaultdict(int)
for w in winners:
counts[w] += 1
# naive Gini
vals = sorted(counts.values())
if not vals:
gini = 0.0
else:
n = len(vals)
come = 0
for i, v in enumerate(vals, 1):
come += i * v
gini = (2*come)/(n*sum(vals)) - (n+1)/n
return {
"county_fips": county_fips,
"period": period or "NA",
"orders": total_orders,
"eligible_pool_mean": None, # populate with more data if needed
"distribution_gini": round(gini, 3),
"winner_counts": counts
}
 
(continued)

# -------------------- CSV Importers --------------------
# Schema: appraisers.csv
# appraiser_id,licenses,counties,forms,inspections,property_types,experience_hours,approvals,quality_index,sla_over_90d,recent_share_90d,unavailable,conflicts
# approvals example: FHA:true|VA:false
def parse_bool(s: str) -> bool:
return str(s).strip().lower() in ("1","true","yes","y")

@app.post("/admin/load_appraisers_csv")
def load_appraisers_csv(path: str = Body(..., embed=True)):
count = 0
with open(path, newline="") as f:
rdr = csv.DictReader(f)
for r in rdr:
approvals = {}
appr_str = r.get("approvals","")
for pair in appr_str.split("|"):
if ":" in pair:
k,v = pair.split(":",1)
approvals[k.strip()] = parse_bool(v)
a = Appraiser(
appraiser_id=r["appraiser_id"].strip(),
licenses=[x.strip() for x in r["licenses"].split("|") if x.strip()],
counties=[x.strip() for x in r["counties"].split("|") if x.strip()],
forms=[x.strip() for x in r["forms"].split("|") if x.strip()],
inspections=[x.strip() for x in r["inspections"].split("|") if x.strip()],
property_types=[x.strip() for x in r["property_types"].split("|") if x.strip()],
experience_hours=int(r["experience_hours"]),
approvals=approvals,
quality_index=float(r.get("quality_index",0.9)),
sla_overage_rate_90d=float(r.get("sla_over_90d",0.0)),
recent_share_90d=float(r.get("recent_share_90d",0.0)),
unavailable=parse_bool(r.get("unavailable","false")),
conflicts=parse_bool(r.get("conflicts","false"))
)
APPRAISERS[a.appraiser_id] = a
count += 1
return {"loaded_appraisers": count}

# Schema: assignments_history.csv (optional seed for analytics)
# order_id,appraiser_id,county_fips,timestamp_iso
@app.post("/admin/load_assignments_history_csv")
def load_assignments_history_csv(path: str = Body(..., embed=True)):
count = 0
with open(path, newline="") as f:
rdr = csv.DictReader(f)
for r in rdr:
# For demo, just warm rotation weights and logs
aid = r["appraiser_id"].strip()
ROTATION_WEIGHT[aid] += 0.2
count += 1
return {"seeded_history_rows": count}

Quickstart (local):

python -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn pydantic
uvicorn app.main:app --reload

Exercise the API (example):

# Load sample appraisers
curl -X POST http://127.0.0.1:8000/admin/load_appraisers_csv \
-H "Content-Type: application/json" \
-d '{"path": "appraisers.csv"}'

# Submit an order
curl -X POST http://127.0.0.1:8000/orders \
-H "Content-Type: application/json" \
-d '{
"client_order_id": "ABC-2025-0001",
"loan_purpose": "purchase",
"property_type": "SFR",
"product_form": "1004",
"inspection": "full",
"complexity_tier": "standard",
"approvals_required": ["FHA:false","VA:false"],
"sla": {"target_days": 7},
"filters": {
"geo_county_fips": "06081",
"geo_radius_miles": 25,
"min_experience_hours": 1500,
"licenses_required": ["CA_CERTIFIED_RESIDENTIAL"],
"form": "1004",
"inspection": "full"
}
}'

III) CSV Schemas, Importers, and Fairness/Quality Metrics​


A) CSV Schemas (copy to your data dictionary)​

appraisers.csv
appraiser_id,licenses,counties,forms,inspections,property_types,experience_hours,approvals,quality_index,sla_over_90d,recent_share_90d,unavailable,conflicts
APR1,"CA_CERTIFIED_RESIDENTIAL","06081|06085","1004|1073","full|desktop","SFR|CONDO",5200,"FHA:true|VA:false",0.92,0.05,0.02,false,false
APR2,"CA_CERTIFIED_RESIDENTIAL","06081","1004","full","SFR",2100,"FHA:false|VA:false",0.88,0.02,0.03,false,false
APR3,"CA_CERTIFIED_RESIDENTIAL","06081|06075","1004|2055","full|exterior","SFR|2-4",3800,"FHA:true|VA:true",0.96,0.10,0.01,false,false

assignments_history.csv (optional seed)

order_id,appraiser_id,county_fips,timestamp_iso
HIST001,APR1,06081,2025-06-01T12:00:00Z
HIST002,APR2,06081,2025-06-02T12:00:00Z
HIST003,APR3,06081,2025-06-03T12:00:00Z

B) Baseline Metrics You Can Compute Immediately​

  • Distribution Gini (already exposed at /analytics/distribution): lower is fairer rotation across the eligible pool.
  • Top-K Concentration: % of assignments captured by top 5 appraisers per county/product.
  • Eligibility vs. Assignment Rate: (assignments / eligible-count) per appraiser; deviations beyond ±2σ flag favoritism or hidden constraints.
  • ROV Rate by Lender/Product: once you wire ROV endpoint, monitor for lender-specific outliers (value shopping).
  • Turn-Time SLA Breach Rate: populate sla_overage_rate_90d from your QC history; cap its scoring impact to avoid covert favoritism.
If you want these additional metrics in the API, I can attach a /analytics/fairness endpoint that ingests your historical CSVs and emits a small report table.

Implementation Notes & Next Steps (practical)​

  1. Security hardening: replace get_client_id() with OAuth2 client-credential flow or mTLS; add role-based scopes (lender, appraiser, admin).
  2. Persistence: swap in PostgreSQL (your environment already includes Postgres) with three tables (appraisers, orders, assignments) and a fourth for immutable logs (assignment_log with SHA-256 chains).
  3. Decision Cards: consider writing the decision card JSON to WORM (e.g., S3 object lock or immudb) for tamper-evidence.
  4. ROV workflow: add POST /assignments/{id}/rov with evidence schema you already liked; ensure automatic anonymization to the appraiser.
  5. Human-in-the-loop: for complex jobs where the eligible set < N_min (e.g., 3), queue a supervisor review step, with the reason logged.

Opinionated closing thought​

From a policy and engineering standpoint, this is “just enough mechanism”: objectively filter, rotate transparently, cap the influence of quality/SLA so they cannot be weaponized, and audit everything. The statute and the code are aligned so that what you promise lawmakers is exactly what the system enforces.

If you want, I will extend this into:

  • a full PostgreSQL schema + migrations,
  • a FastAPI OAuth2 security layer (Authlib), and
  • a small Prolog module that mirrors eligibility for cross-checking the Python engine on test cases."
 
(continued)
CharGPT:
-- 01_core.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- --- Security baseline (optional but recommended) ---
CREATE SCHEMA ch AUTHORIZATION CURRENT_USER;

-- --- OAuth client registry (for lenders/AMCs and service accounts) ---
CREATE TABLE ch.oauth_client (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
client_id TEXT UNIQUE NOT NULL,
client_name TEXT NOT NULL,
client_secret_hash TEXT NOT NULL, -- store bcrypt/argon2 hash
scopes TEXT[] NOT NULL DEFAULT '{}', -- e.g., {orders:write,analytics:read}
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- --- Access tokens (short-lived) ---
CREATE TABLE ch.oauth_token (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
client_id TEXT NOT NULL REFERENCES ch.oauth_client(client_id) ON DELETE CASCADE,
access_token TEXT UNIQUE NOT NULL,
scope TEXT[] NOT NULL DEFAULT '{}',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON ch.oauth_token (client_id);
CREATE INDEX ON ch.oauth_token (expires_at);

-- --- Appraisers ---
CREATE TABLE ch.appraiser (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
appraiser_id TEXT UNIQUE NOT NULL, -- human-stable id (APR123)
licenses TEXT[] NOT NULL, -- ["CA_CERTIFIED_RESIDENTIAL"]
counties TEXT[] NOT NULL, -- ["06081","06085"]
forms TEXT[] NOT NULL, -- ["1004","1073"]
inspections TEXT[] NOT NULL, -- ["full","desktop"]
property_types TEXT[] NOT NULL, -- ["SFR","CONDO"]
experience_hours INTEGER NOT NULL,
approvals JSONB NOT NULL DEFAULT '{}'::jsonb, -- {"FHA":true,"VA":false}
quality_index NUMERIC(4,3) NOT NULL DEFAULT 0.900,
sla_over_90d NUMERIC(4,3) NOT NULL DEFAULT 0.000,
recent_share_90d NUMERIC(4,3) NOT NULL DEFAULT 0.000,
unavailable BOOLEAN NOT NULL DEFAULT FALSE,
conflicts BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ON ch.appraiser (appraiser_id);

-- --- Orders (intake) ---
CREATE TABLE ch."order" (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
order_id TEXT UNIQUE NOT NULL, -- external-facing stable id
client_order_id TEXT NOT NULL,
lender_id TEXT NOT NULL, -- from oauth client_id
loan_purpose TEXT NOT NULL, -- purchase/refi/...
property_type TEXT NOT NULL, -- SFR/CONDO/2-4
product_form TEXT NOT NULL, -- 1004/1073/2055...
inspection TEXT NOT NULL, -- full/desktop/exterior
complexity_tier TEXT NOT NULL CHECK (complexity_tier IN ('standard','complex')),
approvals_required TEXT[] NOT NULL DEFAULT '{}', -- ["FHA:true","VA:false"]
sla_target_days INTEGER NOT NULL,
sla_latest_utc TIMESTAMPTZ,
filter_county_fips TEXT NOT NULL, -- 5-digit
filter_geo_radius INTEGER NOT NULL DEFAULT 25,
filter_min_exp INTEGER NOT NULL DEFAULT 0,
filter_licenses TEXT[] NOT NULL,
filter_form TEXT NOT NULL,
filter_inspection TEXT NOT NULL,
notes TEXT,
status TEXT NOT NULL DEFAULT 'intake_pending',
intake_ts TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ON ch."order" (order_id);
CREATE INDEX ON ch."order" (lender_id);
CREATE INDEX ON ch."order" (filter_county_fips);

-- --- Decision cards (immutable-ish; append only) ---
CREATE TABLE ch.decision_card (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
order_id TEXT NOT NULL REFERENCES ch."order"(order_id) ON DELETE CASCADE,
json JSONB NOT NULL, -- full decision card body
audit_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON ch.decision_card (order_id);

-- --- Assignments (one current per order; declines recorded in log) ---
CREATE TABLE ch.assignment (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
assignment_id TEXT UNIQUE NOT NULL,
order_id TEXT NOT NULL REFERENCES ch."order"(order_id) ON DELETE CASCADE,
appraiser_id TEXT NOT NULL REFERENCES ch.appraiser(appraiser_id),
status TEXT NOT NULL DEFAULT 'tentative', -- tentative/accepted/declined
decline_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ON ch.assignment (assignment_id);
CREATE INDEX ON ch.assignment (order_id);
CREATE INDEX ON ch.assignment (appraiser_id);

-- --- Assignment log (WORM-style: chained hashes for tamper-evidence) ---
CREATE TABLE ch.assignment_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
order_id TEXT NOT NULL,
appraiser_id TEXT,
event TEXT NOT NULL, -- matched/accepted/declined/rov/second_order/...
payload JSONB NOT NULL, -- any details
prev_hash TEXT, -- previous row hash (for chain)
row_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON ch.assignment_log (order_id);

(ENOUGH!!)​


Now what do you think? Do you think "someone" has the audacity to lay off 300,000 federal workers? Hmmmmm.
 
Also, you must begin to feel all that money you pay to your Appraisal Organization ... probably isn't being spent too well.

And so it is. Bad and Good, Good and Bad. More Bad or more Good? Well, in any case the government is going to have to pay "workers" to not work starting at some point and until then we are in for TURMOIL.
 
In a previous post you referred to "donkey" appraisers. Well guess what those "donkey" appraisers are? They are appraisers - appraisers who exhibit "unprofessional and shortcut practices" - and they are out there competing with the appraisers who do things correctly. And, more importantly, they are affecting the public perception of the value of appraisal practice.

Are those "donkey" appraisers just scapegoats? Or are they the real root of the problem for other appraisers?

Unprofessional conduct by others should indeed be called out. But the fact that their conduct is all too often facilitated by unprofessional conduct by an appraiser should not be ignored, as is often the case. There is plenty of blame to go around, but some in this community seems to only butter one side of the bread. :)
Exactly where did I say unprofessional conduct by appraisers should be ignored? To that end, I spent a dozen years at the state taking actions against appraisers (and AMCs) for their shortcomings. All I've said is that lenders, and AMCs when utilized, have been the donkey appraisers best friend, especially in the post HVCC era. I'm in no way saying all appraisers are donkeys but the lenders/AMCs have, by their actions and processes, done a spectacular job of seeking out the donkeys. Stop doing business with donkeys and the donkeys will go away. But as it currently stands, there is high demand for low competency so who's fault is that?
 
Last edited:
  • Like
Reactions: RCA
Find a Real Estate Appraiser - Enter Zip Code

Copyright © 2000-, AppraisersForum.com, All Rights Reserved
AppraisersForum.com is proudly hosted by the folks at
AppraiserSites.com
Back
Top