235 lines
23 KiB
Python
235 lines
23 KiB
Python
#!/usr/bin/env python3
|
||
"""Read-only shape CSV audit. No solver/library dependency; outputs live elsewhere than raw case files."""
|
||
from __future__ import annotations
|
||
import argparse, csv, hashlib, json, math
|
||
from pathlib import Path
|
||
from collections import defaultdict
|
||
|
||
CASES = ('n1_J0p10_iteration_2', 'n1p5_J0_iteration_1', 'n3_J0_seed_R160_curvature')
|
||
|
||
def rows(path):
|
||
with path.open(newline='') as stream:
|
||
reader = csv.DictReader(stream)
|
||
result = list(reader)
|
||
for r in result:
|
||
if None in r or any(v is None for v in r.values()):
|
||
raise ValueError(f'Malformed/incomplete CSV row in {path}: {r}')
|
||
return result
|
||
|
||
def num(r, key): return float(r[key])
|
||
def flag(r, key): return r[key] == '1'
|
||
def norm(values): return math.sqrt(math.fsum(v*v for v in values))
|
||
def ratio(a,b): return a/b if math.isfinite(a) and math.isfinite(b) and b > 0 else math.nan
|
||
|
||
def clean(obj):
|
||
if isinstance(obj, float) and not math.isfinite(obj): return None
|
||
if isinstance(obj, dict): return {str(k):clean(v) for k,v in obj.items()}
|
||
if isinstance(obj, (tuple,list)): return [clean(v) for v in obj]
|
||
return obj
|
||
|
||
def signed_aggregate(rs):
|
||
out = {}
|
||
for kind in ('full','shape','fields','mixed'):
|
||
out[kind+'_remainder_norm'] = norm(num(r,kind+'_remainder_norm') for r in rs)
|
||
out['linear_dot_'+kind] = math.fsum(num(r,'linear_dot_'+kind) for r in rs)
|
||
for name in ('linear_prediction_norm','full_trial_norm','heuristic_roundoff_scale'):
|
||
out[name] = norm(num(r,name) for r in rs)
|
||
for name in ('merit_identity_error','projection_decomposition_error'):
|
||
out[name] = math.fsum(num(r,name) for r in rs)
|
||
for a,b in (('full','shape'),('full','fields'),('full','mixed'),('shape','fields'),('shape','mixed'),('fields','mixed')):
|
||
dot = 0.
|
||
for r in rs:
|
||
na,nb = num(r,a+'_remainder_norm'),num(r,b+'_remainder_norm')
|
||
if na == 0 or nb == 0: continue
|
||
dot += na*nb*num(r,a+'_'+b+'_cosine')
|
||
out[a+'_'+b+'_cosine'] = ratio(dot, out[a+'_remainder_norm']*out[b+'_remainder_norm'])
|
||
out['linear_curvature_cross_term'] = 2*out['linear_dot_full']
|
||
out['curvature_squared_term'] = out['full_remainder_norm']**2
|
||
out['merit_curvature_total'] = out['linear_curvature_cross_term']+out['curvature_squared_term']
|
||
return out
|
||
|
||
def analyze_case(case):
|
||
summary_path = case/'shape_curvature_summary.csv'
|
||
if not summary_path.exists(): return {'status':'pending'}
|
||
summary = {r['metric']:num(r,'value') for r in rows(summary_path)}
|
||
required = ('preparations','accepted_normalized_state_change','accepted_residual_change','accepted_physical_state_change')
|
||
if not all(k in summary for k in required): return {'status':'incomplete', 'summary':summary}
|
||
names = ('preparations','restorations','directions','actions','bounds','remainders','mixed')
|
||
data = {name:rows(case/f'shape_curvature_{name}.csv') for name in names}
|
||
result = {'status':'complete','summary':summary,'sha256':{p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in case.glob('shape_curvature_*.csv')}}
|
||
input_checks=case/'frozen_input_checks.csv'
|
||
if input_checks.exists(): result['input_checks']={r['metric']:num(r,'value') for r in rows(input_checks)}
|
||
replay=case/'replayed_candidate.csv'
|
||
if replay.exists(): result['replayed_candidate']=rows(replay)
|
||
checks = result['checks'] = {}
|
||
checks['accepted_vectors_unchanged'] = all(summary[k] == 0 for k in required[1:])
|
||
checks['direction_split_exact'] = summary['direction_split_error_norm'] == 0
|
||
checks['action_split_within_recorded_heuristic'] = summary['action_linearity_within_heuristic'] == 1
|
||
checks['preparation_count_matches_events'] = summary['preparations'] == len(data['preparations'])
|
||
checks['all_preparations_admissible'] = all(flag(r,'admissible') for r in data['preparations'])
|
||
result['rejections'] = [r for r in data['preparations'] if not flag(r,'admissible')]
|
||
checks['all_state_increments_resolved'] = all(flag(r,'state_increment_resolved') for r in data['remainders'])
|
||
checks['all_alpha_squared_valid'] = all(flag(r,'alpha_squared_valid') for r in data['remainders'])
|
||
result['max_restoration_difference'] = max((num(r,'restoration_difference_norm') for r in data['restorations']),default=0)
|
||
result['actions'] = data['actions']
|
||
result['directions'] = data['directions']
|
||
drift = defaultdict(float)
|
||
for r in data['restorations']: drift[r['block']] = max(drift[r['block']],num(r,'restoration_difference_norm'))
|
||
for r in data['remainders']:
|
||
r['effective_floor'] = max(num(r,'heuristic_roundoff_scale'),8*drift[r['block']])
|
||
r['effective_floor_ratio'] = ratio(num(r,'remainder_norm'),r['effective_floor'])
|
||
r['robustly_above_floor'] = flag(r,'admissible') and flag(r,'state_increment_resolved') and flag(r,'alpha_squared_valid') and r['effective_floor_ratio'] >= 10
|
||
gravity = lambda r: r['block'] in ('gravity_gradient_relation', 'poisson_balance')
|
||
affine = [r for r in data['remainders'] if r['direction']=='fields' and gravity(r)]
|
||
result['fixed_shape_gravity'] = {
|
||
'blocks':sorted({r['block'] for r in affine}),
|
||
'max_remainder':max((num(r,'remainder_norm') for r in affine),default=math.nan),
|
||
'max_effective_floor_ratio':max((r['effective_floor_ratio'] for r in affine),default=math.nan),
|
||
'all_below_recorded_or_restoration_floor': bool(affine) and all(r['effective_floor_ratio'] <= 1 for r in affine),
|
||
'rows':affine,
|
||
}
|
||
by_direction_block = defaultdict(list)
|
||
for r in data['remainders']: by_direction_block[r['direction'],r['block']].append(r)
|
||
slopes = []
|
||
for (direction,block),rs in by_direction_block.items():
|
||
rs.sort(key=lambda r:num(r,'alpha'),reverse=True)
|
||
for high,low in zip(rs,rs[1:]):
|
||
if not high['robustly_above_floor'] or not low['robustly_above_floor']: continue
|
||
slope = math.log(num(high,'remainder_norm')/num(low,'remainder_norm'))/math.log(num(high,'alpha')/num(low,'alpha'))
|
||
slopes.append({'direction':direction,'block':block,'alpha_high':num(high,'alpha'),'alpha_low':num(low,'alpha'),'slope':slope,'high_floor_ratio':high['effective_floor_ratio'],'low_floor_ratio':low['effective_floor_ratio']})
|
||
result['resolved_slopes'] = slopes
|
||
by_alpha = defaultdict(list)
|
||
mixed_by_block = defaultdict(list)
|
||
block_identity_ratios, block_projection_ratios = [], []
|
||
for r in data['mixed']:
|
||
by_alpha[num(r,'alpha')].append(r)
|
||
mixed_by_block[r['block']].append(r)
|
||
if not flag(r,'all_trials_admissible'): continue
|
||
den = num(r,'full_trial_norm')**2 + num(r,'linear_prediction_norm')**2 + 2*abs(num(r,'linear_dot_full')) + num(r,'full_remainder_norm')**2
|
||
block_identity_ratios.append(ratio(abs(num(r,'merit_identity_error')),den))
|
||
pden = math.fsum(abs(num(r,'linear_dot_'+k)) for k in ('full','shape','fields','mixed'))
|
||
if pden > 0: block_projection_ratios.append(ratio(abs(num(r,'projection_decomposition_error')),pden))
|
||
for block,rs in mixed_by_block.items():
|
||
rs.sort(key=lambda r:num(r,'alpha'),reverse=True)
|
||
for high,low in zip(rs,rs[1:]):
|
||
floor_high=max(num(high,'heuristic_roundoff_scale'),24*drift[block])
|
||
floor_low=max(num(low,'heuristic_roundoff_scale'),24*drift[block])
|
||
def robust(r,floor):
|
||
return all(flag(r,key) for key in ('all_trials_admissible','all_state_increments_resolved','alpha_squared_valid')) and num(r,'mixed_remainder_norm') >= 10*floor
|
||
if not robust(high,floor_high) or not robust(low,floor_low): continue
|
||
slopes.append({'direction':'mixed','block':block,'alpha_high':num(high,'alpha'),'alpha_low':num(low,'alpha'),'slope':math.log(num(high,'mixed_remainder_norm')/num(low,'mixed_remainder_norm'))/math.log(num(high,'alpha')/num(low,'alpha')),'high_floor_ratio':ratio(num(high,'mixed_remainder_norm'),floor_high),'low_floor_ratio':ratio(num(low,'mixed_remainder_norm'),floor_low)})
|
||
aggregates = []
|
||
identity_ratios,projection_ratios = [],[]
|
||
for alpha,rs in sorted(by_alpha.items(),reverse=True):
|
||
item = {'alpha':alpha,'all_admissible':all(flag(r,'all_trials_admissible') for r in rs),'all_resolved':all(flag(r,'all_state_increments_resolved') for r in rs)}
|
||
if item['all_admissible']:
|
||
item.update(signed_aggregate(rs))
|
||
item['base_residual_norm'] = summary['base_residual_norm']
|
||
item['trial_over_base'] = ratio(item['full_trial_norm'],summary['base_residual_norm'])
|
||
item['linear_over_base'] = ratio(item['linear_prediction_norm'],summary['base_residual_norm'])
|
||
item['full_remainder_over_alpha_squared'] = ratio(item['full_remainder_norm'],alpha**2)
|
||
full_rows = [r for r in data['remainders'] if r['direction']=='full' and num(r,'alpha') == alpha]
|
||
item['full_effective_floor_norm'] = norm(r['effective_floor'] for r in full_rows)
|
||
item['full_effective_floor_ratio'] = ratio(item['full_remainder_norm'],item['full_effective_floor_norm'])
|
||
item['gravity'] = signed_aggregate([r for r in rs if gravity(r)])
|
||
item['gravity_full_remainder_squared_fraction'] = ratio(item['gravity']['full_remainder_norm']**2,item['full_remainder_norm']**2)
|
||
item['blocks'] = rs
|
||
den = item['full_trial_norm']**2+item['linear_prediction_norm']**2+2*abs(item['linear_dot_full'])+item['full_remainder_norm']**2
|
||
item['merit_identity_relative_error'] = ratio(abs(item['merit_identity_error']),den)
|
||
pden = math.fsum(abs(item['linear_dot_'+k]) for k in ('full','shape','fields','mixed'))
|
||
item['projection_decomposition_relative_error'] = ratio(abs(item['projection_decomposition_error']),pden)
|
||
identity_ratios.append(item['merit_identity_relative_error'])
|
||
projection_ratios.append(item['projection_decomposition_relative_error'])
|
||
aggregates.append(item)
|
||
result['alphas'] = aggregates
|
||
result['max_merit_identity_relative_error'] = max(identity_ratios,default=math.nan)
|
||
result['max_projection_decomposition_relative_error'] = max(projection_ratios,default=math.nan)
|
||
result['max_block_merit_identity_relative_error'] = max(block_identity_ratios,default=math.nan)
|
||
result['max_block_projection_decomposition_relative_error'] = max(block_projection_ratios,default=math.nan)
|
||
result['remainder_rows'] = data['remainders']
|
||
return result
|
||
|
||
def make_markdown(results,provenance):
|
||
lines = ['# Shape-curvature analysis','',f"Focused executable source `{provenance.get('commit','unknown')}`; binary SHA-256 `{provenance.get('binary_sha256','unknown')}`.",'',
|
||
'This analysis reads the saved full, shape-only, and fields-only residual probes. Norms and projections use the existing normalization. The mixed remainder is a vector subtraction before its norm. No solver was run by this analysis.','',
|
||
'Source expectation: gravity has the form Fg=M(q)g+BᵀPhi and Fp=B g−S(q)rho. With q fixed, it is affine in all remaining fields; shape dependence and products of shape changes with field changes can supply second-order remainders. See [residual assembly](/Users/tboudreaux/Programming/4DSTAR/MeanField/libmeanfield/impl/operators/gravity_field.cpp:415) and [Jacobian shape/source actions](/Users/tboudreaux/Programming/4DSTAR/MeanField/libmeanfield/impl/operators/gravity_field_jacobian.cpp:242).','',
|
||
'Arithmetic checks use the reported preparation history and the final restoration drift. Slopes are reported only when both adjacent residual remainders exceed the larger of the recorded floor and eight times final restoration drift by at least 10×, and both increments are resolved. These floors are heuristic, not certified assembly-error bounds.','']
|
||
lines += ['## Common-cap comparison','',
|
||
'The signed columns below divide squared-merit contributions by each case’s own ‖F0‖². Their sum equals (‖Ftrial‖²−‖L‖²)/‖F0‖². They are evaluated at each direction’s common admissible cap; these are generally different alpha values.','',
|
||
'| case | alpha | ‖Ftrial‖/‖F0‖ | 2<L,Rs>/F0² | 2<L,Rf>/F0² | 2<L,Rm>/F0² | ‖Rfull‖²/F0² | total curvature/F0² |',
|
||
'|---|---:|---:|---:|---:|---:|---:|---:|']
|
||
for name,result in results.items():
|
||
if result['status'] != 'complete':
|
||
lines += [f"| {name} | {result['status']} | | | | | | |"]
|
||
continue
|
||
if not result['alphas'] or not result['alphas'][0]['all_admissible']:
|
||
lines += [f"| {name} | no complete common-cap triple | | | | | | |"]
|
||
continue
|
||
a=result['alphas'][0]; den=result['summary']['base_residual_norm']**2
|
||
lines += [f"| {name} | {a['alpha']:.7g} | {a['trial_over_base']:.8g} | {2*a['linear_dot_shape']/den:.7g} | {2*a['linear_dot_fields']/den:.7g} | {2*a['linear_dot_mixed']/den:.7g} | {a['curvature_squared_term']/den:.7g} | {a['merit_curvature_total']/den:.7g} |"]
|
||
lines += ['']
|
||
if all(r['status']=='complete' for r in results.values()):
|
||
lines += ['Across these three saved directions, every one of the shape helper’s 66 preparations is admissible and every accepted-vector change and restoration drift is exactly zero. The coupled gravity Taylor remainders have resolved slopes near two, while fields-only gravity remainders stay at approximately 2.5e-14–4.0e-14. This supports geometry–field curvature along the tested directions; no first-derivative inconsistency is visible in the resolved regime. The numerical floor is heuristic: the n1.5 and n3 fields-only maxima exceed it by factors 1.04 and 1.39, which is recorded explicitly.','',
|
||
'The practical mechanisms differ. The rotating n1 direction is blocked at its original full step by pressure admissibility; its sampled safe interval decreases the residual. The n1.5 full step is admissible and is strongly rejected by curvature in the residual metric. The n3 saved direction encounters both an enthalpy restriction and a curvature penalty at the largest tested safe step, but smaller sampled steps improve the residual. Its originating linear solve also missed the 3% gate.','']
|
||
lines += ['## First-order gravity cancellation','',
|
||
'These are cancellations between the two parts of each selected correction. They are not condition-number or symmetry measurements.','',
|
||
'| case | block | norm(Jshape) | norm(Jfields) | norm(Jfull) | shape/fields cosine |',
|
||
'|---|---|---:|---:|---:|---:|']
|
||
for name,result in results.items():
|
||
if result['status'] != 'complete': continue
|
||
for r in result['actions']:
|
||
if r['block'] not in ('gravity_gradient_relation','poisson_balance'): continue
|
||
lines += [f"| {name} | {r['block']} | {num(r,'shape_action_norm'):.8g} | {num(r,'fields_action_norm'):.8g} | {num(r,'full_action_norm'):.8g} | {num(r,'shape_fields_cosine'):.12g} |"]
|
||
lines += ['']
|
||
for name,result in results.items():
|
||
lines += ['## '+name,'']
|
||
if result['status'] != 'complete':
|
||
lines += ['Status: '+result['status']+'.','']; continue
|
||
s=result['summary']; checks=result['checks']; aff=result['fixed_shape_gravity']
|
||
if name=='n1_J0p10_iteration_2':
|
||
lines += ['**Interpretation:** the sampled admissible interval improves the residual, although mixed curvature makes it worse than the linear prediction. The original full-step pressure rejection is a separate admissibility event. The largest-step mixed signed projection is adverse, while the shape-only signed projection is favorable. The last alpha is limited by residual arithmetic and does not diagnose loss of second-order consistency.','']
|
||
elif name=='n1p5_J0_iteration_1':
|
||
lines += ['**Interpretation:** this is an admissible, curvature-driven merit rejection. At alpha=1 the linear prediction almost removes the residual, while the actual residual grows by over 12×. Alpha=.1 remains merit-worse; alpha=.01 improves it. Gravity shape and mixed remainders dominate despite partial cancellation. Their squared remainder term dominates the full-step merit cost; at alpha=.1 both shape and mixed signed projections are adverse. The fields-only gravity maximum exceeds the chosen heuristic floor by only 4.2%, remaining at approximately 3e-14 absolute; that minor heuristic exceedance is not evidence of nonlinear fixed-shape gravity.','']
|
||
elif name=='n3_J0_seed_R160_curvature':
|
||
lines += ['**Interpretation:** the largest common safe step is admissible but merit-worse; the next decade improves the residual. The mixed remainder is the largest component and is strongly aligned with the full remainder. Its signed projection onto the linear prediction is nevertheless favorable at the largest alpha, as is the fields-only projection: the adverse total curvature cost comes from the squared remainder norm overwhelming those favorable cross terms. At smaller resolved alphas the net curvature slightly improves the linear prediction. The saved R160 direction retains true linear defect 0.0402590213 and had failed the 0.03 gate; this probe is not a live Newton convergence result. Fields-only gravity stays below 4e-14 absolute, up to 1.393× the heuristic floor.','']
|
||
lines += [f"Frozen ‖F‖={s['base_residual_norm']:.9g}; ‖p‖={s['full_direction_norm']:.9g}; common alpha cap={s['common_alpha_cap']:.9g}. {int(s['preparations'])} preparations. Accepted-vector changes are all zero: **{checks['accepted_vectors_unchanged']}**. Direction split exact: **{checks['direction_split_exact']}**. Jacobian split error {s['action_split_error_norm']:.3e}, recorded scale {s['action_linearity_heuristic_scale']:.3e}; maximum per-block restoration drift {result['max_restoration_difference']:.3e}.",'',
|
||
f"All preparations admissible: **{checks['all_preparations_admissible']}**; all increments resolved: **{checks['all_state_increments_resolved']}**. Fields-only gravity maximum remainder {aff['max_remainder']:.3e}, maximum ratio to the effective heuristic floor {aff['max_effective_floor_ratio']:.3e}. Every sampled fields-only gravity remainder lies below that floor: **{aff['all_below_recorded_or_restoration_floor']}**.",'',
|
||
f"Maximum aggregate merit-identity relative error {result['max_merit_identity_relative_error']:.3e}; projection-decomposition relative error {result['max_projection_decomposition_relative_error']:.3e}.",'',
|
||
'| alpha | ‖Ftrial‖/‖F0‖ | ‖L‖/‖F0‖ | ‖Rfull‖/alpha² | full R / floor | ‖Rs‖ | ‖Rf‖ | ‖Rm‖ | gravity share of ‖Rfull‖² |','|---:|---:|---:|---:|---:|---:|---:|---:|---:|']
|
||
for a in result['alphas']:
|
||
if not a['all_admissible']: lines += [f"| {a['alpha']:.6g} | inadmissible | | | | | | | |"]
|
||
else: lines += [f"| {a['alpha']:.6g} | {a['trial_over_base']:.8g} | {a['linear_over_base']:.8g} | {a['full_remainder_over_alpha_squared']:.8g} | {a['full_effective_floor_ratio']:.3g} | {a['shape_remainder_norm']:.4e} | {a['fields_remainder_norm']:.4e} | {a['mixed_remainder_norm']:.4e} | {a['gravity_full_remainder_squared_fraction']:.6%} |"]
|
||
lines += ['', '| alpha | 2<L,Rs> | 2<L,Rf> | 2<L,Rm> | ‖Rfull‖² | curvature contribution to squared merit |','|---:|---:|---:|---:|---:|---:|']
|
||
for a in result['alphas']:
|
||
if a['all_admissible']: lines += [f"| {a['alpha']:.6g} | {2*a['linear_dot_shape']:.7e} | {2*a['linear_dot_fields']:.7e} | {2*a['linear_dot_mixed']:.7e} | {a['curvature_squared_term']:.7e} | {a['merit_curvature_total']:.7e} |"]
|
||
lines += ['', 'Positive signed terms increase the squared residual relative to the linear prediction; negative terms decrease it. The quadratic remainder-norm term contains component cross terms, so the signed projections alone are not a unique additive attribution of the entire curvature cost.','',
|
||
'| direction | residual block | resolved intervals | slope range | smallest resolved alpha |','|---|---|---:|---:|---:|']
|
||
grouped_slopes=defaultdict(list)
|
||
for p in result['resolved_slopes']: grouped_slopes[p['direction'],p['block']].append(p)
|
||
for (direction,block),ps in grouped_slopes.items():
|
||
lines += [f"| {direction} | {block} | {len(ps)} | {min(p['slope'] for p in ps):.6f}–{max(p['slope'] for p in ps):.6f} | {min(p['alpha_low'] for p in ps):.3g} |"]
|
||
lines += ['']
|
||
lines += ['## Discriminating next experiment','',
|
||
'Where the full Jacobian action is small because shape and field actions cancel, suppressing the shape component alone would destroy that first-order balance. A targeted hypothesis is that the current straight Newton trial loses the geometry–gravity balance at second order. Test it by holding each saved trial’s shape, density, and other material/scalar coordinates fixed, solving only its linear gravity subsystem to a documented true defect, and comparing the completed trial against the original one. Record all residual blocks, the new hydrostatic/force response, interior diagnostics and virial; lowering the gravity residual alone is insufficient. This is a proposed diagnostic, not a demonstrated solver improvement or a recommendation to promote a new policy.','',
|
||
'The completed comparison should preserve the original direction and trial as a control, disclose solve cost, and separate the pressure-admissibility obstruction from residual-metric rejection. A linear one-way preconditioner’s previous failure does not test this nonlinear conditional completion. These probes do not justify clipping enthalpy or deleting shape coordinates.','',
|
||
'Before promoting such a change, localize the important remainder and signed-merit contributions by material and exterior element assembly, preserving their shared-DOF cross terms. The existing aggregate gravity norms cannot tell whether an apparently large improvement benefits the interior or mainly removes exterior error. Interior consistency and virial ≤1e-5 remain the promotion criteria.','',
|
||
'## Interpretation limits','',
|
||
'A resolved second-order remainder, first-order decay of its difference-quotient error, and arithmetic-level fields-only gravity remainder support geometric/field curvature along these particular directions. They do not prove every Jacobian column correct. Jacobian additivity tests linearity and restoration, not agreement with the nonlinear derivative by itself.','',
|
||
'These residual-block aggregates do not separate interior from exterior elements. They neither locate a physical error nor identify a symmetry or near-null mode. A large correction or curvature may coexist with a weak normalized direction, but this split is not a condition-number measurement. No new model was accepted and these probes do not establish a virial milestone.','']
|
||
return '\n'.join(lines)
|
||
|
||
def main():
|
||
ap=argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument('--campaign',type=Path,default=Path(__file__).resolve().parent,
|
||
help='Campaign root containing focused/; writes shape_checks.json and shape_analysis.md here. Defaults to the script directory.')
|
||
args=ap.parse_args()
|
||
campaign=args.campaign; focused=campaign/'focused'
|
||
provenance=json.loads((focused/'provenance.json').read_text())
|
||
results={case:analyze_case(focused/case) for case in CASES}
|
||
out={'schema_version':1,'source_commit':provenance.get('commit'),'binary_sha256':provenance.get('binary_sha256'),'cases':results}
|
||
(campaign/'shape_checks.json').write_text(json.dumps(clean(out),indent=2,allow_nan=False)+'\n')
|
||
(campaign/'shape_analysis.md').write_text(make_markdown(results,provenance))
|
||
for name,r in results.items(): print(name,r['status'])
|
||
|
||
if __name__=='__main__':main()
|