facetted-interfaces/history/FIAM_v0.2_Draft6/tools/declared_observed_lens.py
tegwick 1353267569 Establish FIAM repository governance baseline
Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a087b0-7b1e-7531-8ef3-6525ebaf7ee7
2026-09-09 21:53:43 +02:00

139 lines
10 KiB
Python
Executable file

#!/usr/bin/env python3
"""Execute the FIAM Declared-vs-Observed Lens with estate coverage findings."""
from __future__ import annotations
import argparse
from pathlib import Path
import yaml
RECON_EXT='org.fiam.reconciliation_candidates'
def evidence_records(a):
e=a.get('evidence');return [e] if isinstance(e,dict) else [x for x in (e or []) if isinstance(x,dict)]
def has_mode(a,modes):return any(e.get('mode') in modes for e in evidence_records(a))
def confidence(a):return max([float(e.get('confidence',0)) for e in evidence_records(a)] or [0])
def source_summary(a):
if not a:return []
out=[]
for e in evidence_records(a):
s=e.get('source');out.append({'mode':e.get('mode'),'type':s.get('type') if isinstance(s,dict) else None,'ref':s.get('ref') if isinstance(s,dict) else s,'locator':s.get('locator') if isinstance(s,dict) else None,'confidence':e.get('confidence')})
return out
def index(doc):
obj={};kinds={};op_contract={}
for sec,kind in [('participants','participant'),('nodes','system-node'),('ports','port'),('contracts','interface-contract'),('endpoints','endpoint'),('bindings','binding'),('channels','channel'),('information_resources','information-resource'),('information_shapes','information-shape'),('interactions','interaction'),('interaction_flows','interaction-flow'),('artifacts','artifact'),('zones','zone'),('security','security-profile')]:
for x in doc.get(sec,[]) or []:
if x.get('id'):obj[x['id']]=x;kinds[x['id']]=kind
if sec=='contracts':
for o in x.get('operations',[]) or []:
if o.get('id'):obj[o['id']]=o;kinds[o['id']]='operation';op_contract[o['id']]=x['id']
return obj,kinds,op_contract
def ancestors(term,terms):
out=set();stack=list((terms.get(term) or {}).get('broader') or [])
while stack:
x=stack.pop()
if x in out:continue
out.add(x);stack.extend((terms.get(x) or {}).get('broader') or [])
return out
def depth(term,terms,memo=None):
memo=memo or {}
if term in memo:return memo[term]
br=(terms.get(term) or {}).get('broader') or [];memo[term]=0 if not br else 1+max(depth(x,terms,memo) for x in br);return memo[term]
def choose(assertions,strategy,terms):
if not assertions:return None
if strategy=='canon-hierarchy':
vals=[a for a in assertions if isinstance(a.get('value'),str) and a.get('value') in terms]
if vals:return max(vals,key=lambda a:(depth(a['value'],terms),confidence(a)))
return max(assertions,key=confidence)
def compare_values(dv,ov,strategy,terms):
if dv is None and ov is None:return 'unknown'
if dv is None:return 'observed-only'
if ov is None:return 'declared-only'
if dv==ov:return 'exact'
if strategy=='canon-hierarchy' and isinstance(dv,str) and isinstance(ov,str) and dv in terms and ov in terms:
oa=ancestors(ov,terms);da=ancestors(dv,terms)
if dv in oa:return 'compatible-refinement'
if ov in da:return 'observed-less-specific'
return 'conflict'
def owner_for(entity,obj,kinds,op_contract):
k=kinds.get(entity);bid=None
if k=='binding':bid=entity
elif k=='operation':
cid=op_contract.get(entity);bid=next((x for x,b in obj.items() if kinds.get(x)=='binding' and b.get('contract')==cid and b.get('provider_port')),None)
elif k=='endpoint':bid=next((x for x,b in obj.items() if kinds.get(x)=='binding' and entity in (b.get('endpoints') or []) and b.get('provider_port')),None)
elif k in {'channel','information-resource'}:
nid=(obj.get(entity) or {}).get('owner');return {'node_ref':nid,'name':(obj.get(nid) or {}).get('name')} if nid else None
if not bid:return None
p=obj.get((obj.get(bid) or {}).get('provider_port'),{});nid=p.get('owner');return {'node_ref':nid,'name':(obj.get(nid) or {}).get('name')} if nid else None
def entity_mode(ids,assertions,mode):
return {i for i in ids if any(a.get('subject')==i and has_mode(a,{mode}) for a in assertions)}
def coverage(doc,obj,kinds,op_contract,assertions):
# Entity coverage is intentionally relation-based and keeps ambiguous near-matches visible.
declared_mode='fiam.evidence.declared';observed_mode='fiam.evidence.runtime-observed'
opids=[i for i,k in kinds.items() if k=='operation'];chids=[i for i,k in kinds.items() if k=='channel']
declared_ops=entity_mode(opids,assertions,declared_mode);observed_ops=entity_mode(opids,assertions,observed_mode)
declared_ch=entity_mode(chids,assertions,declared_mode);observed_ch=entity_mode(chids,assertions,observed_mode)
corr={}
for a in assertions:
if a.get('assertion_kind')=='relation' and a.get('polarity')=='asserted' and a.get('predicate')=='fiam.relation.corresponds-to':corr.setdefault(a.get('subject'),set()).add(a.get('object_ref'))
candidates={x.get('observed_ref'):x for x in (doc.get('extensions',{}) or {}).get(RECON_EXT,[]) or []}
def classify(declared,observed,kind):
seen_declared={o for s in observed for o in corr.get(s,set()) if o in declared};rows=[]
for d in sorted(declared):
rows.append({'entity_kind':kind,'classification':'observed' if d in seen_declared else 'declared-only','declared_ref':d,'owner':owner_for(d,obj,kinds,op_contract)})
for o in sorted(observed):
if corr.get(o,set()) & declared:continue
cand=candidates.get(o) or {};status=cand.get('status') or 'unmatched';rows.append({'entity_kind':kind,'classification':'ambiguous' if status in {'ambiguous','near-match'} else 'observed-only','observed_ref':o,'candidate_status':status,'candidates':cand.get('candidates',[])})
return rows
rows=classify(declared_ops,observed_ops,'operation')+classify(declared_ch,observed_ch,'channel')
summary={}
for r in rows:summary[r['classification']]=summary.get(r['classification'],0)+1
return {'summary':dict(sorted(summary.items())),'findings':rows}
def execute(doc,canon,lens):
obj,kinds,op_contract=index(doc);terms={t['id']:t for t in canon.get('terms',[])};assertions=doc.get('assertions',[]) or [];declared=set(lens.get('declared_evidence_modes') or ['fiam.evidence.declared']);observed=set(lens.get('observed_evidence_modes') or ['fiam.evidence.runtime-observed'])
pairs=[]
for a in assertions:
if a.get('assertion_kind')=='relation' and a.get('polarity')=='asserted' and a.get('predicate')==lens['pair_relation']:
s,o=a.get('subject'),a.get('object_ref')
if s in kinds and o in kinds and kinds[s]==kinds[o]:pairs.append((s,o,a))
findings=[];seen=set()
for observed_ref,declared_ref,pa in pairs:
k=kinds.get(observed_ref)
for comp in lens['comparisons']:
if comp.get('subject_kinds') and k not in comp['subject_kinds']:continue
pred=comp['predicate'];strategy=comp['strategy'];da=[a for a in assertions if a.get('assertion_kind')=='property' and a.get('polarity')=='asserted' and a.get('subject')==declared_ref and a.get('predicate')==pred and has_mode(a,declared)];oa=[a for a in assertions if a.get('assertion_kind')=='property' and a.get('polarity')=='asserted' and a.get('subject')==observed_ref and a.get('predicate')==pred and has_mode(a,observed)]
if not da and not oa:continue
d=choose(da,strategy,terms);o=choose(oa,strategy,terms);cls=compare_values(d.get('value') if d else None,o.get('value') if o else None,strategy,terms);key=(declared_ref,observed_ref,pred,cls)
if key in seen:continue
seen.add(key);findings.append({'declared_ref':declared_ref,'observed_ref':observed_ref,'entity_kind':k,'predicate':pred,'classification':cls,'declared_value':d.get('value') if d else None,'observed_value':o.get('value') if o else None,'owner':owner_for(declared_ref,obj,kinds,op_contract),'pair_evidence':source_summary(pa),'declared_evidence':source_summary(d),'observed_evidence':source_summary(o)})
counts={}
for f in findings:counts[f['classification']]=counts.get(f['classification'],0)+1
cov=coverage(doc,obj,kinds,op_contract,assertions)
return {'lens':lens['id'],'lens_version':lens.get('version'),'model':doc.get('instance',{}).get('id'),'summary':{'pairs':len(pairs),'property_findings':len(findings),'classifications':dict(sorted(counts.items())),'coverage':cov['summary']},'findings':findings,'coverage_findings':cov['findings'],'reconciliation_candidates':(doc.get('extensions',{}) or {}).get(RECON_EXT,[]) or []}
def markdown(r):
lines=[f"# {r['lens']} result",'',f"Model: `{r.get('model')}`",'', '## Property comparison','']
for k,v in r['summary']['classifications'].items():lines.append(f'- **{k}:** {v}')
lines += ['','## Coverage','']+[f'- **{k}:** {v}' for k,v in r['summary']['coverage'].items()]
lines += ['','## Property findings','']
for f in r['findings']:
lines += [f"### {f['classification']} — {f['predicate']}",f"- Declared: `{f['declared_ref']}` = `{f.get('declared_value')}`",f"- Observed: `{f['observed_ref']}` = `{f.get('observed_value')}`"]
if f.get('owner'):lines.append(f"- Architectural owner: `{f['owner'].get('node_ref')}` ({f['owner'].get('name')})")
lines.append('')
lines += ['## Coverage findings','']
for f in r['coverage_findings']:
ref=f.get('declared_ref') or f.get('observed_ref');lines.append(f"- **{f['classification']}** {f['entity_kind']}: `{ref}`"+(f" ({f.get('candidate_status')})" if f.get('candidate_status') else ''))
return '\n'.join(lines)+'\n'
def main():
root=Path(__file__).resolve().parents[1];ap=argparse.ArgumentParser(description=__doc__);ap.add_argument('model',type=Path);ap.add_argument('--canon',type=Path,default=root/'canon/FIAM-Canon-v0.2.yaml');ap.add_argument('--lens',type=Path,default=root/'lenses/declared-vs-observed.yaml');ap.add_argument('-o','--output',type=Path,required=True);ap.add_argument('--markdown',type=Path);args=ap.parse_args();doc=yaml.safe_load(args.model.read_text());canon=yaml.safe_load(args.canon.read_text());lens=yaml.safe_load(args.lens.read_text());r=execute(doc,canon,lens);args.output.parent.mkdir(parents=True,exist_ok=True);args.output.write_text(yaml.safe_dump(r,sort_keys=False,allow_unicode=True,width=120));
if args.markdown:args.markdown.write_text(markdown(r))
print(yaml.safe_dump(r['summary'],sort_keys=False).strip())
if __name__=='__main__':main()