Level 5 — MasteryDesign Principles

Design Principles

90 minutes60 marksprintable — key stays hidden on paper

Chapter: 2.2 Design Principles Level: 5 — Mastery (cross-domain synthesis: software design + physics simulation + mathematical modelling) Time Limit: 90 minutes Total Marks: 60

Instructions: Answer all three questions. Code may be written in Python-like pseudocode or valid Python. Where mathematical justification is requested, use ...... notation. Marks are awarded for design reasoning, not merely working code.


Question 1 — Physics Integrator Zoo (SOLID + Strategy + DIP) — [24 marks]

You are building a numerical simulation engine for a projectile under gravity plus optional drag. The equation of motion is

dvdt=gkmvv,dxdt=v.\frac{d\mathbf{v}}{dt} = \mathbf{g} - \frac{k}{m}\,\|\mathbf{v}\|\,\mathbf{v}, \qquad \frac{d\mathbf{x}}{dt} = \mathbf{v}.

A junior engineer wrote one giant Simulator class whose step() method contains an if method == "euler": ... elif method == "rk4": ... chain, and also hard-codes k, m, g as constants inside the method.

(a) Identify two SOLID principles violated by this design and, for each, name the exact code smell and the consequence for maintainability. [6]

(b) Redesign using the Strategy pattern for the integrator and Dependency Inversion for the force model. Give an interface/abstract-class sketch (Integrator with advance(state, dt, force_fn); a ForceModel abstraction) and show how Simulator depends only on abstractions. [8]

(c) Implement Explicit Euler and RK4 as two Integrator strategies for the 1-D vertical case (g=gy^\mathbf{g}=-g\hat{y}, no drag). Then, using g=9.81m/s2g=9.81\,\mathrm{m/s^2}, v0=0v_0=0, and one step of dt=1dt=1 s starting from v=0v=0, compute the velocity after one step for both integrators and explain, using the local truncation error order, why they agree here but would differ for a nonlinear force. [6]

(d) Argue whether adding a new VerletIntegrator requires modifying existing classes. Relate your answer explicitly to the Open/Closed Principle. [4]


Question 2 — Decorator Algebra for Signal Filters (DRY + Structural Patterns) — [20 marks]

A DSP pipeline applies transformations to a discrete signal x[n]x[n]. Each filter is a pure function f:RRf:\mathbb{R}\to\mathbb{R} applied sample-wise, and filters compose.

(a) You need gain (g(x)=axg(x)=ax), bias (b(x)=x+cb(x)=x+c), and clip (clip(x)=min(max(x,L),L)\text{clip}(x)=\min(\max(x,-L),L)). A colleague writes 6 classes for the 6 orderings of {gain, bias, clip}. State which principle this violates and why, then show how the Decorator pattern reduces this to 3 reusable components plus composition. [6]

(b) Composition of gain-then-bias vs bias-then-gain is not commutative. Prove mathematically that for the linear filters ga(x)=axg_a(x)=ax and bc(x)=x+cb_c(x)=x+c:

(bcga)(x)(gabc)(x)whenever a1 and c0,(b_c \circ g_a)(x) \ne (g_a \circ b_c)(x) \quad \text{whenever } a\neq 1 \text{ and } c\neq 0,

and give the exact difference (gabc)(x)(bcga)(x)(g_a\circ b_c)(x) - (b_c\circ g_a)(x). Explain what this implies about why Decorator order is part of the contract (not a hidden detail). [8]

(c) For a=2a=2, c=3c=3, input x=5x=5, compute both orderings numerically and confirm your formula from (b). Then explain how the Flyweight pattern could apply if you had 10⁶ identical gain(2) decorators, and estimate the memory-order saving. [6]


Question 3 — YAGNI vs Extensibility Trade-off Proof (KISS + YAGNI + Separation of Concerns) — [16 marks]

A team must choose between Design A (a plugin AbstractFactory framework, 500 LOC, supporting arbitrary future export formats) and Design B (a single to_csv() function, 20 LOC) for a tool that today exports only CSV.

(a) Model maintenance cost as C(n)=F+mnC(n) = F + m\cdot n, where nn = number of formats ever needed, FF = fixed framework cost, mm = marginal per-format cost. Suppose FA=500F_A=500, mA=15m_A=15, FB=0F_B=0, mB=60m_B=60 (adding a format to B means partial rewrite). Find the break-even n\*n^\* at which CA(n)=CB(n)C_A(n)=C_B(n). [6]

(b) Given the tool has needed exactly one format for 3 years with no roadmap for more, use your n\*n^\* result to justify Design B via YAGNI and KISS. State precisely the condition on expected nn under which YAGNI is the correct engineering call. [6]

(c) Identify one Separation of Concerns improvement that applies to Design B regardless of the YAGNI decision (i.e., a change that is cheap now and never premature). [4]

Answer keyMark scheme & solutions

Question 1 [24]

(a) [6] — 2 marks per principle identified + 1 each for smell/consequence.

  • Single Responsibility Principle (SRP): Simulator handles both integration strategy selection and force computation and orchestration. Smell: "God class." Consequence: any change to force law OR integrator forces edits to the same class → merge conflicts, fragile tests. [3]
  • Open/Closed Principle (OCP): the if/elif chain must be modified to add a new integrator. Smell: "switch on type." Consequence: not closed for modification; risk of regression each time. [3]
  • (DIP also acceptable: hard-coded constants = dependency on concretions.)

(b) [8]

class Integrator(ABC):
    @abstractmethod
    def advance(self, state, dt, force_fn): ...   # returns new state
 
class ForceModel(ABC):
    @abstractmethod
    def accel(self, state): ...                   # dv/dt
 
class Simulator:
    def __init__(self, integrator: Integrator, force: ForceModel):
        self._integ = integrator      # depends on ABSTRACTION (DIP)
        self._force = force
    def run(self, state, dt, steps):
        for _ in range(steps):
            state = self._integ.advance(state, dt, self._force.accel)
        return state
  • 3 marks: Integrator abstraction with advance.
  • 3 marks: ForceModel abstraction; constants now injected, not hard-coded (DIP: high-level Simulator depends on interfaces).
  • 2 marks: Simulator constructor takes abstractions (constructor injection).

(c) [6] 1-D: a(v)=ga(v) = -g (constant, no drag). Start v0=0v_0=0, dt=1dt=1.

  • Euler: v1=v0+dta=0+1(9.81)=9.81v_1 = v_0 + dt\cdot a = 0 + 1\cdot(-9.81) = -9.81 m/s. [1.5]
  • RK4: all four slopes equal g-g since aa is constant: k1=k2=k3=k4=9.81k_1=k_2=k_3=k_4=-9.81, so v1=v0+dt6(k1+2k2+2k3+k4)=9.81v_1 = v_0 + \frac{dt}{6}(k_1+2k_2+2k_3+k_4) = -9.81 m/s. [1.5]
  • Explanation [3]: For a constant derivative the exact solution is linear in tt; Euler's local truncation error O(dt2)O(dt^2) has coefficient v=a=0\propto v''=a'=0, and RK4's O(dt5)O(dt^5) term also vanishes. Both are exact here. For a nonlinear force (e.g. drag v2\propto v^2), a0a'\neq 0, so Euler's O(dt2)O(dt^2) error appears while RK4 remains O(dt5)O(dt^5) — they diverge.

(d) [4]

  • Adding VerletIntegrator = new class implementing Integrator.advance; no existing class is modified — inject it into Simulator. [2]
  • This satisfies OCP: system is open for extension (new strategy) and closed for modification (Simulator, existing integrators untouched). Contrast with the original if/elif, which required modification. [2]

Question 2 [20]

(a) [6]

  • Principle violated: DRY (and KISS). Writing 6 classes duplicates the same 3 transformation logics; a bug fix in gain must be repeated. [2]
  • Decorator solution: 3 leaf/decorator components each wrapping a Signal source; composition builds any ordering at runtime. [4]
class Filter(ABC):
    def apply(self, x): ...
class Source(Filter):
    def apply(self, x): return x
class Gain(Filter):
    def __init__(self, inner, a): self.inner, self.a = inner, a
    def apply(self, x): return self.a * self.inner.apply(x)
class Bias(Filter):
    def __init__(self, inner, c): self.inner, self.c = inner, c
    def apply(self, x): return self.inner.apply(x) + self.c
# any ordering: Gain(Bias(Source(), c), a)  vs  Bias(Gain(Source(), a), c)

(b) [8] Compute both compositions:

  • (gabc)(x)=ga(x+c)=a(x+c)=ax+ac(g_a\circ b_c)(x) = g_a(x+c) = a(x+c) = ax + ac. [2]
  • (bcga)(x)=bc(ax)=ax+c(b_c\circ g_a)(x) = b_c(ax) = ax + c. [2]
  • Difference: (gabc)(x)(bcga)(x)=(ax+ac)(ax+c)=c(a1)(g_a\circ b_c)(x) - (b_c\circ g_a)(x) = (ax+ac)-(ax+c) = c(a-1). [2]
  • This is nonzero exactly when c0c\neq0 and a1a\neq1 ⇒ non-commutative. [1]
  • Design implication [1]: Since order changes the output, the sequence of Decorators is semantically significant and must be part of the pipeline's documented contract — it is not an interchangeable implementation detail.

(c) [6] a=2,c=3,x=5a=2, c=3, x=5:

  • gabcg_a\circ b_c: 2(5+3)=162(5+3)=16. [1.5]
  • bcgab_c\circ g_a: 25+3=132\cdot5+3=13. [1.5]
  • Difference =1613=3=c(a1)=3(21)=3=16-13=3 = c(a-1)=3(2-1)=3. ✓ Formula confirmed. [1]
  • Flyweight [2]: All 10610^6 Gain(2) decorators share identical intrinsic state (the factor 2). Store one shared flyweight object; per-use extrinsic state (the wrapped signal) passed in. Memory drops from O(106)O(10^6) objects to O(1)O(1) shared + references ≈ six orders of magnitude saving on the intrinsic data.

Question 3 [16]

(a) [6] Set CA(n)=CB(n)C_A(n)=C_B(n): 500+15n=0+60n    500=45n    n\*=50045=100911.11.500 + 15n = 0 + 60n \;\Rightarrow\; 500 = 45n \;\Rightarrow\; n^\* = \frac{500}{45} = \frac{100}{9} \approx 11.11. [6] So the framework pays off only once ~12 distinct formats are needed.

(b) [6]

  • Actual/expected n=1n = 1 (one format, stable 3 years, no roadmap) is far below n\*11.1n^\*\approx11.1. [2]
  • Therefore CB(1)=60<CA(1)=515C_B(1)=60 < C_A(1)=515; Design B is ~8.6× cheaper today. YAGNI: don't build the abstract-factory speculative infrastructure for formats that "aren't gonna be needed"; KISS: 20 LOC is trivially understandable and testable. [2]
  • Precise condition [2]: YAGNI is correct whenever the expected number of formats E[n]<n\*=100/9\mathbb{E}[n] < n^\* = 100/9. Equivalently, choose B while E[n]11\mathbb{E}[n]\lesssim 11; only invest in A once demand credibly exceeds the break-even.

(c) [4]

  • Separation of Concerns improvement always worthwhile: split to_csv() into (1) a data-serialization concern (row→string formatting) and (2) an I/O concern (writing to file/stream). [2]
  • Rationale: decouples formatting from output destination, enabling unit-testing formatting without touching disk — cheap, non-speculative, and does not build unused format machinery, so it does not violate YAGNI. [2]

[
  {"claim":"Euler and RK4 give -9.81 for one step of constant accel -g from v=0, dt=1",
   "code":"g=Rational(981,100); dt=1; v0=0; euler=v0+dt*(-g); k=-g; rk4=v0+dt/6*(k+2*k+2*k+k); result=(euler==Rational(-981,100)) and (rk4==Rational(-981,100)) and (euler==rk4)"},
  {"claim":"Composition difference (g_a o b_c)-(b_c o g_a) = c(a-1)",
   "code":"a,c,x=symbols('a c x'); lhs=a*(x+c); rhs=a*x+c; result=simplify((lhs-rhs)-c*(a-1))==0"},
  {"claim":"Numeric check a=2,c=3,x=5 gives 16 and 13 with difference 3",
   "code":"a,c,x=2,3,5; g_of_b=a*(x+c); b_of_g=a*x+c; result=(g_of_b==16) and (b_of_g==13) and (g_of_b-b_of_g==c*(a-1))"},
  {"claim":"Break-even n* solves 500+15n=60n giving 100/9",
   "code":"n=symbols('n'); sol=solve(Eq(500+15*n,60*n),n); result=sol[0]==Rational(100,9)"}
]